|null $families * @return array */ public function buildRelease(?array $families = null, ?string $releaseId = null): array { return $this->withLock(function () use ($families, $releaseId): array { return $this->buildReleaseUnlocked($families, $releaseId); }); } /** * @return array */ public function publish(?string $releaseId = null): array { return $this->withLock(function () use ($releaseId): array { $manifest = $releaseId !== null ? $this->releases->readManifest($releaseId) : $this->buildReleaseUnlocked(); if ($manifest === null) { throw new \RuntimeException('Sitemap release [' . $releaseId . '] does not exist.'); } $releaseId = (string) $manifest['release_id']; $validation = $this->validator->validate($releaseId); if (! ($validation['ok'] ?? false)) { $manifest['status'] = 'failed'; $manifest['validation'] = $validation; $this->releases->writeManifest($releaseId, $manifest); throw new \RuntimeException('Sitemap release validation failed.'); } $manifest['status'] = 'published'; $manifest['published_at'] = now()->toAtomString(); $manifest['validation'] = $validation; $this->releases->writeManifest($releaseId, $manifest); $this->releases->activate($releaseId); $deleted = $this->cleanup->cleanup(); return $manifest + ['cleanup_deleted' => $deleted]; }); } /** * @return array */ public function rollback(?string $releaseId = null): array { return $this->withLock(function () use ($releaseId): array { if ($releaseId === null) { $activeReleaseId = $this->releases->activeReleaseId(); foreach ($this->releases->listReleases() as $release) { if ((string) ($release['status'] ?? '') === 'published' && (string) ($release['release_id'] ?? '') !== $activeReleaseId) { $releaseId = (string) $release['release_id']; break; } } } if (! is_string($releaseId) || $releaseId === '') { throw new \RuntimeException('No rollback release is available.'); } $manifest = $this->releases->readManifest($releaseId); if ($manifest === null) { throw new \RuntimeException('Rollback release [' . $releaseId . '] not found.'); } $this->releases->activate($releaseId); return $manifest + ['rolled_back_at' => now()->toAtomString()]; }); } /** * @template TReturn * @param callable(): TReturn $callback * @return TReturn */ private function withLock(callable $callback): mixed { $lock = Cache::lock('sitemaps:publish-flow', max(30, (int) config('sitemaps.releases.lock_seconds', 900))); if (! $lock->get()) { throw new \RuntimeException('Another sitemap build or publish operation is already running.'); } try { return $callback(); } finally { $lock->release(); } } /** * @param list|null $families * @return array */ private function buildReleaseUnlocked(?array $families = null, ?string $releaseId = null): array { $selectedFamilies = $families ?: $this->build->enabledFamilies(); $releaseId ??= $this->releases->generateReleaseId(); $familyManifest = []; $documents = [ SitemapCacheService::INDEX_DOCUMENT => $this->releases->documentRelativePath(SitemapCacheService::INDEX_DOCUMENT), ]; $totalUrls = 0; $rootIndex = $this->build->buildIndex(true, false, $selectedFamilies); $this->releases->putDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT, (string) $rootIndex['content']); foreach ($selectedFamilies as $family) { $builder = app(SitemapRegistry::class)->get($family); if ($builder === null) { continue; } $canonicalNames = $this->build->canonicalDocumentNamesForFamily($family); $shardNames = []; $urlCount = 0; foreach ($canonicalNames as $documentName) { $built = $this->build->buildNamed($documentName, true, false); if ($built === null) { throw new \RuntimeException('Failed to build sitemap document [' . $documentName . '].'); } $this->releases->putDocument($releaseId, $documentName, (string) $built['content']); $documents[$documentName] = $this->releases->documentRelativePath($documentName); if ($built['type'] !== SitemapTarget::TYPE_INDEX) { $urlCount += (int) $built['url_count']; } if (str_starts_with($documentName, $family . '-') && ! str_ends_with($documentName, '-index')) { $shardNames[] = $documentName; } } $totalUrls += $urlCount; $familyManifest[$family] = [ 'family' => $family, 'entry_name' => app(SitemapShardService::class)->rootEntryName($builder), 'documents' => $canonicalNames, 'shards' => $shardNames, 'url_count' => $urlCount, 'shard_count' => count($shardNames), 'type' => $builder->name() === (string) config('sitemaps.news.google_variant_name', 'news-google') ? SitemapTarget::TYPE_GOOGLE_NEWS : (count($shardNames) > 0 ? SitemapTarget::TYPE_INDEX : SitemapTarget::TYPE_URLSET), ]; } $manifest = [ 'release_id' => $releaseId, 'status' => 'built', 'built_at' => now()->toAtomString(), 'published_at' => null, 'families' => $familyManifest, 'documents' => $documents, 'totals' => [ 'families' => count($familyManifest), 'documents' => count($documents), 'urls' => $totalUrls, ], ]; $this->releases->writeManifest($releaseId, $manifest); $validation = $this->validator->validate($releaseId); $manifest['status'] = ($validation['ok'] ?? false) ? 'validated' : 'failed'; $manifest['validation'] = $validation; $this->releases->writeManifest($releaseId, $manifest); $this->releases->writeBuildReport($releaseId, $validation); return $manifest; } }