diff --git a/config/install/ultimate_cron.job.portfolio_refresh.yml b/config/install/ultimate_cron.job.portfolio_refresh.yml new file mode 100644 index 0000000..87b64ff --- /dev/null +++ b/config/install/ultimate_cron.job.portfolio_refresh.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + module: + - oit +title: 'Portfolio Refresh' +id: portfolio_refresh +weight: 12 +module: oit +callback: '\Drupal\oit\Services\CronManager::refreshPortfolio' +scheduler: + id: crontab + configuration: + rules: + - '0 */8 * * *' + catch_up: 28800 +launcher: + id: serial + configuration: + timeouts: + lock_timeout: 3600 + launcher: + thread: 0 +logger: + id: database + configuration: + method: '3' + expire: 1209600 + retain: 1000 diff --git a/oit.info.yml b/oit.info.yml index 216e895..79eccd4 100644 --- a/oit.info.yml +++ b/oit.info.yml @@ -3,5 +3,6 @@ type: module description: 'Replacement for OIT General module in D7. General functionality will be placed in here.' dependencies: - drupal:block + - ultimate_cron:ultimate_cron core_version_requirement: ^11 diff --git a/oit.services.yml b/oit.services.yml index ebfc8d4..54e2cf7 100644 --- a/oit.services.yml +++ b/oit.services.yml @@ -58,6 +58,9 @@ services: oit.domain: class: Drupal\oit\Plugin\Domain arguments: ["@token"] + oit.portfolio: + class: Drupal\oit\Services\PortfolioData + arguments: ['@cache.default', '@cache_tags.invalidator'] oit.route_subscriber: class: Drupal\oit\Services\RouteSubscriber tags: diff --git a/src/Plugin/Block/PortfolioBlock.php b/src/Plugin/Block/PortfolioBlock.php index 4e265f2..a4e6b01 100644 --- a/src/Plugin/Block/PortfolioBlock.php +++ b/src/Plugin/Block/PortfolioBlock.php @@ -4,8 +4,10 @@ use Drupal\Component\Utility\Xss; use Drupal\Core\Block\BlockBase; -use Drupal\oit\Plugin\GoogleSheetsFetch; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\oit\Plugin\GoogleSheetsProcess; +use Drupal\oit\Services\PortfolioData; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * OIT Projects Portfolio block. @@ -16,7 +18,43 @@ * Report") * ) */ -class PortfolioBlock extends BlockBase { +class PortfolioBlock extends BlockBase implements ContainerFactoryPluginInterface { + + /** + * The portfolio data service. + * + * @var \Drupal\oit\Services\PortfolioData + */ + protected $portfolioData; + + /** + * Constructs a new PortfolioBlock. + * + * @param array $configuration + * The plugin configuration. + * @param string $plugin_id + * The plugin ID. + * @param mixed $plugin_definition + * The plugin definition. + * @param \Drupal\oit\Services\PortfolioData $portfolio_data + * The portfolio data service. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, PortfolioData $portfolio_data) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->portfolioData = $portfolio_data; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self { + return new self( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('oit.portfolio') + ); + } /** * {@inheritdoc} @@ -34,8 +72,10 @@ public function build() { * Render array for the portfolio table. */ public function fetchPortfolio() { - $fetchData = new GoogleSheetsFetch('1k4-Csp29uLZbh_g2nhuhpq3dVBgZ6zWFK20BXP1rL_s', 0, 0); - $gsheet_returned_data = $fetchData->getFetchedSheet(); + // The raw sheet data is fetched and cached by the oit.portfolio service so + // the slow remote request happens off the request-render path (warmed by + // cron) rather than on every render-cache miss. + $gsheet_returned_data = $this->portfolioData->getSheetData(); $processData = new GoogleSheetsProcess($gsheet_returned_data, 'a,b,c,d,e,f,g,h,i,j', 'custom'); $data = $processData->getProcessedData(); $header = [ @@ -122,6 +162,14 @@ public function fetchPortfolio() { 'library' => ['oit/table_search', 'oit/oit_projects'], ], ]; + // Set a single top-level cache entry so the tag bubbles to the node page + // render cache, the anonymous Internal Page Cache and the Pantheon Global + // CDN. The max-age is a freshness backstop only — the remote fetch is gated + // behind the oit.portfolio data cache, not this value. + $html['#cache'] = [ + 'tags' => [PortfolioData::TAG], + 'max-age' => PortfolioData::TTL, + ]; return $html; } diff --git a/src/Services/CronManager.php b/src/Services/CronManager.php index 405e111..5383d6c 100644 --- a/src/Services/CronManager.php +++ b/src/Services/CronManager.php @@ -74,6 +74,21 @@ public static function topTutorials() { } } + /** + * Warm the Portfolio block cache by re-fetching the Google Sheet. + * + * Live environment only. In non-live environments the block's cold-cache + * fallback fetch handles dev/test. + */ + public static function refreshPortfolio() { + $env = getenv('PANTHEON_ENVIRONMENT'); + if ($env !== 'live') { + \Drupal::logger('oit')->notice('Portfolio refresh skipped. This is the @env environment.', ['@env' => $env]); + return; + } + \Drupal::service('oit.portfolio')->refresh(); + } + /** * Delete old news nodes that are not linked anywhere on the site. * diff --git a/src/Services/PortfolioData.php b/src/Services/PortfolioData.php new file mode 100644 index 0000000..35db422 --- /dev/null +++ b/src/Services/PortfolioData.php @@ -0,0 +1,141 @@ +cacheBackend = $cache_backend; + $this->cacheTagsInvalidator = $cache_tags_invalidator; + } + + /** + * Gets the raw sheet data, populating the cache on a cold miss. + * + * On a cold miss this performs a one-off live fetch and caches a non-empty + * result for 26 hours so the table is never empty on a successful fetch. It + * does not invalidate the cache tag — the render that triggered it caches + * itself fresh anyway. + * + * @return array + * The raw fetched sheet data. + */ + public function getSheetData(): array { + if ($cached = $this->cacheBackend->get(self::CID)) { + return $cached->data; + } + + $data = $this->fetchRaw(); + $this->writeCache($data); + return $data; + } + + /** + * Forces a live fetch and warms the cache. + * + * Writes a non-empty result for 26 hours and, when the write actually + * happened, invalidates the cache tag so any rendered block rebuilds + * immediately with fresh data. On an empty fetch nothing is written and the + * tag is not invalidated, leaving the warm data and render cache untouched. + */ + public function refresh(): void { + $data = $this->fetchRaw(); + if ($this->writeCache($data)) { + $this->cacheTagsInvalidator->invalidateTags([self::TAG]); + } + } + + /** + * Performs the live network fetch of the sheet. + * + * Isolated as the single network seam so unit tests can override it with + * canned or empty data. + * + * @return array + * The raw fetched sheet data. + */ + protected function fetchRaw(): array { + return (new GoogleSheetsFetch(self::SHEET_KEY, self::SHEET_GID, 0))->getFetchedSheet(); + } + + /** + * Writes the data to the cache, guarding against empty results. + * + * Never caches an empty result so a transient upstream failure cannot pin an + * empty table or clobber a good 26h entry. Centralizes the non-empty guard + * and the 26h TTL so they cannot drift apart between the read and write + * paths. + * + * @param array $data + * The data to cache. + * + * @return bool + * TRUE if the cache was written, FALSE if the data was empty. + */ + private function writeCache(array $data): bool { + if (empty($data)) { + return FALSE; + } + $this->cacheBackend->set(self::CID, $data, time() + self::TTL); + return TRUE; + } + +} diff --git a/tests/src/Unit/Services/PortfolioDataTest.php b/tests/src/Unit/Services/PortfolioDataTest.php new file mode 100644 index 0000000..e990d39 --- /dev/null +++ b/tests/src/Unit/Services/PortfolioDataTest.php @@ -0,0 +1,158 @@ +cacheBackend = $this->createMock(CacheBackendInterface::class); + $this->cacheTagsInvalidator = $this->createMock(CacheTagsInvalidatorInterface::class); + } + + /** + * Builds a testable service with a canned fetchRaw() result. + * + * @param array $fetched + * The data fetchRaw() should return. + * + * @return \Drupal\oit\Services\PortfolioData + * The testable service. + */ + protected function getService(array $fetched): PortfolioData { + return new class($this->cacheBackend, $this->cacheTagsInvalidator, $fetched) extends PortfolioData { + + /** + * Canned fetch result. + * + * @var array + */ + private $fetched; + + public function __construct(CacheBackendInterface $cache_backend, CacheTagsInvalidatorInterface $cache_tags_invalidator, array $fetched) { + parent::__construct($cache_backend, $cache_tags_invalidator); + $this->fetched = $fetched; + } + + /** + * {@inheritdoc} + */ + protected function fetchRaw(): array { + return $this->fetched; + } + + }; + } + + /** + * Cache hit returns cached data without fetching or writing. + */ + public function testGetSheetDataCacheHit(): void { + $cached = (object) ['data' => [['row']]]; + $this->cacheBackend->expects($this->once()) + ->method('get') + ->with(PortfolioData::CID) + ->willReturn($cached); + $this->cacheBackend->expects($this->never())->method('set'); + $this->cacheTagsInvalidator->expects($this->never())->method('invalidateTags'); + + $service = $this->getService([['should-not-be-used']]); + $this->assertSame([['row']], $service->getSheetData()); + } + + /** + * Cold miss with non-empty fetch writes 26h TTL and returns data, no tag. + */ + public function testGetSheetDataColdMissNonEmpty(): void { + $data = [['a'], ['b']]; + $now = time(); + $this->cacheBackend->method('get')->willReturn(FALSE); + $this->cacheBackend->expects($this->once()) + ->method('set') + ->with( + PortfolioData::CID, + $data, + $this->callback(fn ($expire) => is_int($expire) && $expire >= $now + PortfolioData::TTL && $expire <= $now + PortfolioData::TTL + 2) + ); + $this->cacheTagsInvalidator->expects($this->never())->method('invalidateTags'); + + $service = $this->getService($data); + $this->assertSame($data, $service->getSheetData()); + } + + /** + * Cold miss with empty fetch does not write. + */ + public function testGetSheetDataColdMissEmpty(): void { + $this->cacheBackend->method('get')->willReturn(FALSE); + $this->cacheBackend->expects($this->never())->method('set'); + $this->cacheTagsInvalidator->expects($this->never())->method('invalidateTags'); + + $service = $this->getService([]); + $this->assertSame([], $service->getSheetData()); + } + + /** + * Refresh with non-empty fetch writes the cache and invalidates the tag once. + */ + public function testRefreshNonEmpty(): void { + $data = [['a'], ['b']]; + $now = time(); + $this->cacheBackend->expects($this->once()) + ->method('set') + ->with( + PortfolioData::CID, + $data, + $this->callback(fn ($expire) => is_int($expire) && $expire >= $now + PortfolioData::TTL && $expire <= $now + PortfolioData::TTL + 2) + ); + $this->cacheTagsInvalidator->expects($this->once()) + ->method('invalidateTags') + ->with([PortfolioData::TAG]); + $service = $this->getService($data); + $service->refresh(); + } + + /** + * Refresh with empty fetch writes nothing and does not invalidate. + */ + public function testRefreshEmpty(): void { + $this->cacheBackend->expects($this->never())->method('set'); + $this->cacheTagsInvalidator->expects($this->never())->method('invalidateTags'); + + $service = $this->getService([]); + $service->refresh(); + } + +}