diff --git a/internal/publishing/product_visibility.go b/internal/publishing/product_visibility.go new file mode 100644 index 0000000..19dc8ef --- /dev/null +++ b/internal/publishing/product_visibility.go @@ -0,0 +1,205 @@ +package publishing + +import ( + "context" + "errors" + "maps" + "sort" + "strings" +) + +// Optional so repositories and coordinators implementing the original full +// installation contract keep working. Website switches always use that path. +type productVisibilityReader interface { + ActivePublication(context.Context, string) (ActivePublication, bool, error) + ActiveProductRouteHistory(context.Context, string) ([]RouteHistory, error) +} + +// InstallProductVisibility runs under the same post-commit admission gate as a +// full install. Only changed immutable files and asset references are checked. +func (e *Engine) InstallProductVisibility(ctx context.Context, productID string) error { + if e.deferVisibility.Load() { + e.deferredVisibilityDirty.Store(true) + return nil + } + repository, ok := e.repository.(productVisibilityReader) + if !ok || e.index == nil || e.index.unhealthy != nil || e.index.assetReferences == nil { + return e.installLocked(ctx) + } + fail := func(err error) error { + e.index = &publicIndex{unhealthy: err} + return err + } + _, _, epoch, err := e.repository.ActiveWebsiteConfiguration(ctx) + if err != nil { + return fail(err) + } + disabled, generation, err := e.repository.WebsiteCustomCSSState(ctx) + if err != nil { + return fail(err) + } + if epoch != e.index.siteEpoch || disabled != e.index.customCSSDisabled || generation != e.index.runtimeGeneration { + return e.installLocked(ctx) + } + activation, found, err := repository.ActivePublication(ctx, productID) + if err != nil { + return fail(err) + } + var assets []assetTarget + if found { + if activation.SiteEpoch != epoch { + return e.installLocked(ctx) + } + if activation.ProductID != productID { + return fail(errors.New("active publication does not match Product")) + } + if err := e.validateUnit(activation); err != nil { + return fail(err) + } + assets, err = e.assetsForView(ctx, activation.View, false) + if err != nil { + return fail(err) + } + } + history, err := repository.ActiveProductRouteHistory(ctx, productID) + if err != nil { + return fail(err) + } + next := *e.index + next.byID = maps.Clone(next.byID) + next.byRoute = maps.Clone(next.byRoute) + next.byAsset = maps.Clone(next.byAsset) + next.assetReferences = maps.Clone(next.assetReferences) + next.productAssets = maps.Clone(next.productAssets) + next.history = maps.Clone(next.history) + if old, exists := next.byID[productID]; exists { + delete(next.byID, productID) + for _, suffix := range []string{"", ".json", ".md"} { + delete(next.byRoute, old.Route+suffix) + } + } + for _, id := range next.productAssets[productID] { + next.assetReferences[id]-- + if next.assetReferences[id] == 0 { + delete(next.assetReferences, id) + delete(next.byAsset, id) + } + } + delete(next.productAssets, productID) + for route, item := range next.history { + if item.ProductID == productID { + delete(next.history, route) + } + } + for _, item := range history { + if item.State == "redirect" || item.State == "gone" { + next.history[item.Route] = item + } + } + if found { + for _, suffix := range []string{"", ".json", ".md"} { + if _, conflict := next.byRoute[activation.Route+suffix]; conflict { + return fail(errors.New("active publication route conflicts")) + } + } + next.byID[productID] = activation + next.byRoute[activation.Route] = routeTarget{publication: activation, file: "index.html", contentType: "text/html; charset=utf-8"} + next.byRoute[activation.Route+".json"] = routeTarget{publication: activation, file: "product.json", contentType: "application/json; charset=utf-8"} + next.byRoute[activation.Route+".md"] = routeTarget{publication: activation, file: "product.md", contentType: "text/markdown; charset=utf-8"} + for _, asset := range assets { + next.byAsset[asset.asset.ID] = asset + next.assetReferences[asset.asset.ID]++ + next.productAssets[productID] = append(next.productAssets[productID], asset.asset.ID) + } + } + next.views = make([]PublicView, 0, len(next.byID)) + for _, view := range e.index.views { + if view.ID != productID { + next.views = append(next.views, view) + } + } + if found { + view := activation.View + at := sort.Search(len(next.views), func(i int) bool { + return next.views[i].PartNumber > view.PartNumber || next.views[i].PartNumber == view.PartNumber && next.views[i].ID >= view.ID + }) + next.views = append(next.views, PublicView{}) + copy(next.views[at+1:], next.views[at:]) + next.views[at] = view + } + // An old aggregate can contain revoked text, counts, or old routes. Deny it + // immediately; a bounded worker batch pre-generates its replacement outside + // the admission gate, and installs it only against this exact snapshot. + next.machine = nil + next.specInventory = maps.Clone(next.specInventory) + delete(next.specInventory, productID) + if found { + next.specInventory[productID] = SpecCandidateBinding{ProductID: activation.ProductID, PublicRevision: activation.PublicRevision, SourceRevision: activation.SourceRevision, SiteEpoch: activation.SiteEpoch, VisibilityGeneration: activation.VisibilityGeneration, ProjectionVersion: SpecProjectionVersion, SpecIDs: SpecProjectionIDs(activation.View)} + } + // The canonical adapter digest serializes every Product's numeric facts. + // Compute it alongside the bounded derived refresh, never per activation. + next.specDigest = "" + next.specDigestError = nil + e.index = &next + e.specDirty.Store(true) + e.Wake() + return nil +} + +func isMachinePath(path string) bool { + return path == "/sitemap.xml" || path == "/robots.txt" || path == "/llms.txt" || path == CatalogManifestPath || strings.HasPrefix(path, "/catalog/products-") && strings.HasSuffix(path, ".json") +} + +func (e *Engine) trackPublicationDependencies(productID string, revision int64) { + e.gate.Lock() + defer e.gate.Unlock() + if e.pendingDependencies == nil { + e.pendingDependencies = make(map[string]int64) + } + if revision > e.pendingDependencies[productID] { + e.pendingDependencies[productID] = revision + } +} + +func (e *Engine) refreshMachine(ctx context.Context) error { + e.machineRefreshMu.Lock() + defer e.machineRefreshMu.Unlock() + e.gate.RLock() + snapshot := e.index + if snapshot == nil && len(e.pendingDependencies) == 0 { + e.gate.RUnlock() + return nil // No visibility installation occurred (including drain). + } + if snapshot == nil || snapshot.unhealthy != nil { + e.gate.RUnlock() + return ErrPublicUnavailable + } + pending := maps.Clone(e.pendingDependencies) + e.gate.RUnlock() + if snapshot.machine == nil { + machine, err := MachineRepresentations(snapshot.views, snapshot.site, e.baseURL, snapshot.siteEpoch) + if err != nil { + return err + } + e.gate.Lock() + if e.index != snapshot { + e.gate.Unlock() + return nil // A newer install remains dirty for the next bounded pass. + } + next := *snapshot + next.machine = machine + e.index = &next + e.gate.Unlock() + } + for id, revision := range pending { + if err := e.repository.ConvergePublicationDependencies(ctx, id, revision); err != nil { + return err + } + e.gate.Lock() + if e.pendingDependencies[id] == revision { + delete(e.pendingDependencies, id) + } + e.gate.Unlock() + } + return nil +} diff --git a/internal/publishing/product_visibility_test.go b/internal/publishing/product_visibility_test.go new file mode 100644 index 0000000..8205dd0 --- /dev/null +++ b/internal/publishing/product_visibility_test.go @@ -0,0 +1,80 @@ +package publishing + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +// Keep real staging and admission while making a catalogue-wide read fail. +// A Product commit has enough information to read only that Product instead. +type productVisibilityRepository struct { + *workerRefreshRepository + readError error +} + +func (r *productVisibilityRepository) ActivePublications(context.Context) ([]ActivePublication, error) { + return nil, errors.New("unrelated catalogue must not be reloaded") +} + +func (r *productVisibilityRepository) ActivePublication(ctx context.Context, id string) (ActivePublication, bool, error) { + if r.readError != nil { + return ActivePublication{}, false, r.readError + } + return r.workerRefreshRepository.ActivePublication(ctx, id) +} + +func installTestProduct(ctx context.Context, coordinator VisibilityCoordinator, id string) error { + if incremental, ok := coordinator.(interface { + InstallProductVisibility(context.Context, string) error + }); ok { + return incremental.InstallProductVisibility(ctx, id) + } + return coordinator.InstallVisibility(ctx) +} + +func (r *productVisibilityRepository) ActivatePublication(ctx context.Context, _ Intent, a ActivePublication, coordinator VisibilityCoordinator) (bool, error) { + coordinator.LockVisibility() + defer coordinator.UnlockVisibility() + a.VisibilityGeneration = int64(len(r.active) + 10) + r.active = append(r.active, a) + return true, installTestProduct(ctx, coordinator, a.ProductID) +} + +func TestProductPublicationDoesNotReloadUnchangedUnits(t *testing.T) { + e, base := workerRefreshFixture(t) + r := &productVisibilityRepository{workerRefreshRepository: base} + e.repository = r + old := r.active[0] + // Previously admitted immutable units do not need a new manifest validation + // just because another Product becomes public. Startup still validates all. + if err := os.Remove(filepath.Join(e.root, "units", old.ArtifactID, "manifest.json")); err != nil { + t.Fatal(err) + } + if worked, err := e.ProcessOne(t.Context()); err != nil || !worked { + t.Fatalf("publish another Product: worked=%v err=%v", worked, err) + } + for _, route := range []string{old.Route, "/products/first"} { + lease, err := e.AdmitPath(route) + if err != nil { + t.Fatalf("admit %s: %v", route, err) + } + lease.File.Close() + } +} + +func TestProductVisibilityReadFailureFailsClosed(t *testing.T) { + e, base := workerRefreshFixture(t) + e.repository = &productVisibilityRepository{workerRefreshRepository: base, readError: errors.New("committed Product cannot be read")} + e.LockVisibility() + err := installTestProduct(t.Context(), e, base.active[0].ProductID) + e.UnlockVisibility() + if err == nil { + t.Fatal("expected installation failure") + } + if _, err := e.AdmitPath(base.active[0].Route); !errors.Is(err, ErrPublicUnavailable) { + t.Fatalf("old visibility was admitted after installation failed: %v", err) + } +} diff --git a/internal/publishing/protocol.go b/internal/publishing/protocol.go index 2afd635..9ac312c 100644 --- a/internal/publishing/protocol.go +++ b/internal/publishing/protocol.go @@ -221,6 +221,8 @@ type publicIndex struct { byID map[string]ActivePublication byRoute map[string]routeTarget byAsset map[string]assetTarget + assetReferences map[string]int + productAssets map[string][]string history map[string]RouteHistory views []PublicView invalid []ActivePublication @@ -252,6 +254,8 @@ type Engine struct { specCandidateQueries atomic.Uint64 specFallbackQueries atomic.Uint64 processMu sync.Mutex + machineRefreshMu sync.Mutex + pendingDependencies map[string]int64 // protected by gate deferVisibility atomic.Bool deferredVisibilityDirty atomic.Bool repository Repository @@ -409,6 +413,11 @@ func (e *Engine) processWorkerBatch(ctx context.Context) (processed bool, err er e.processMu.Lock() defer e.processMu.Unlock() defer e.refreshSpecSearch(ctx, false) + defer func() { + if refreshErr := e.refreshMachine(ctx); err == nil { + err = refreshErr + } + }() for i := 0; i < 100; i++ { if err := ctx.Err(); err != nil { return processed, err @@ -426,6 +435,9 @@ func (e *Engine) ProcessOne(ctx context.Context) (bool, error) { e.processMu.Lock() defer e.processMu.Unlock() processed, err := e.processOne(ctx) + if refreshErr := e.refreshMachine(ctx); err == nil { + err = refreshErr + } e.refreshSpecSearch(ctx, false) return processed, err } @@ -480,8 +492,8 @@ func (e *Engine) processOne(ctx context.Context) (bool, error) { } if !activated { _ = e.repository.CompletePublicationIntent(ctx, intent.ID, "superseded") - } else if err := e.repository.ConvergePublicationDependencies(ctx, activation.ProductID, activation.SourceRevision); err != nil { - return true, err + } else { + e.trackPublicationDependencies(activation.ProductID, activation.SourceRevision) } return true, nil } @@ -507,6 +519,11 @@ func (e *Engine) ProcessBatch(ctx context.Context, limit int) (processed int, er e.deferVisibility.Store(true) defer func() { defer e.refreshSpecSearch(ctx, false) + defer func() { + if refreshErr := e.refreshMachine(ctx); err == nil { + err = refreshErr + } + }() e.deferVisibility.Store(false) if !e.deferredVisibilityDirty.Swap(false) { return @@ -535,7 +552,8 @@ func (e *Engine) Revoke(ctx context.Context, request RevokeRequest) error { if err := e.repository.RevokeProduct(ctx, request, e); err != nil { return err } - return e.repository.ConvergePublicationDependencies(ctx, request.ProductID, request.ExpectedRevision+1) + e.trackPublicationDependencies(request.ProductID, request.ExpectedRevision+1) + return e.refreshMachine(ctx) } func (e *Engine) PreviewSiteRoutes(ctx context.Context, config SiteRouteConfig) (SiteRoutePreview, error) { @@ -590,6 +608,18 @@ func (e *Engine) PublishSiteRoutes(ctx context.Context, request SiteRouteRequest func (e *Engine) Reconcile(ctx context.Context) error { e.gate.Lock() + var revoked map[string]int64 + if repository, ok := e.repository.(interface { + RevokedPublicationDependencies(context.Context) (map[string]int64, error) + }); ok { + var err error + revoked, err = repository.RevokedPublicationDependencies(ctx) + if err != nil { + e.index = &publicIndex{unhealthy: err} + e.gate.Unlock() + return err + } + } err := e.installLocked(ctx) var invalid []ActivePublication var active []ActivePublication @@ -627,6 +657,11 @@ func (e *Engine) Reconcile(ctx context.Context) error { return err } } + for id, revision := range revoked { + if err := e.repository.ConvergePublicationDependencies(ctx, id, revision); err != nil { + return err + } + } return nil } @@ -665,7 +700,7 @@ func (e *Engine) installLocked(ctx context.Context) error { e.index = &publicIndex{unhealthy: err} return err } - next := &publicIndex{byID: make(map[string]ActivePublication), byRoute: make(map[string]routeTarget), byAsset: make(map[string]assetTarget), history: make(map[string]RouteHistory), site: configuration, siteEpoch: siteEpoch, customCSSDisabled: customCSSDisabled, runtimeGeneration: runtimeGeneration} + next := &publicIndex{byID: make(map[string]ActivePublication), byRoute: make(map[string]routeTarget), byAsset: make(map[string]assetTarget), assetReferences: make(map[string]int), productAssets: make(map[string][]string), history: make(map[string]RouteHistory), site: configuration, siteEpoch: siteEpoch, customCSSDisabled: customCSSDisabled, runtimeGeneration: runtimeGeneration} siteAssets, err := e.assetsForSite(ctx, configuration, false) if err != nil { e.index = &publicIndex{unhealthy: err} @@ -673,6 +708,7 @@ func (e *Engine) installLocked(ctx context.Context) error { } for _, asset := range siteAssets { next.byAsset[asset.asset.ID] = asset + next.assetReferences[asset.asset.ID]++ } for _, activation := range activations { if err := e.validateUnit(activation); err != nil { @@ -694,6 +730,8 @@ func (e *Engine) installLocked(ctx context.Context) error { next.byRoute[activation.Route+".md"] = routeTarget{publication: activation, file: "product.md", contentType: "text/markdown; charset=utf-8"} for _, asset := range assets { next.byAsset[asset.asset.ID] = asset + next.assetReferences[asset.asset.ID]++ + next.productAssets[activation.ProductID] = append(next.productAssets[activation.ProductID], asset.asset.ID) } } history, err := e.repository.ActiveRouteHistory(ctx) @@ -702,14 +740,6 @@ func (e *Engine) installLocked(ctx context.Context) error { return err } for _, item := range history { - if _, active := next.byRoute[item.Route]; active { - continue - } - if item.State == "redirect" { - if _, targetActive := next.byRoute[item.TargetRoute]; !targetActive { - continue - } - } if item.State == "redirect" || item.State == "gone" { next.history[item.Route] = item } @@ -1107,6 +1137,13 @@ func (e *Engine) ServePath(w http.ResponseWriter, r *http.Request) bool { if errors.Is(err, os.ErrNotExist) { e.gate.RLock() history, exists := e.index.history[r.URL.Path] + if _, active := e.index.byRoute[r.URL.Path]; active || e.index.unhealthy != nil { + exists = false + } + if history.State == "redirect" { + target, active := e.index.byRoute[history.TargetRoute] + exists = exists && active && target.publication.ProductID == history.ProductID + } e.gate.RUnlock() if !exists { return false @@ -1245,7 +1282,12 @@ func (e *Engine) ServeMachinePath(w http.ResponseWriter, r *http.Request) bool { return true } representation, exists := e.index.machine[r.URL.Path] + pending := e.index.machine == nil && isMachinePath(r.URL.Path) e.gate.RUnlock() + if pending { + http.Error(w, "public machine output temporarily unavailable", http.StatusServiceUnavailable) + return true + } if !exists { return false } diff --git a/internal/publishing/spec_search_backend.go b/internal/publishing/spec_search_backend.go index f07fafc..7ae5f8e 100644 --- a/internal/publishing/spec_search_backend.go +++ b/internal/publishing/spec_search_backend.go @@ -110,6 +110,9 @@ func (e *Engine) refreshSpecSearch(ctx context.Context, force bool) error { } } e.gate.RUnlock() + if err == nil && digest == "" { + digest, err = specActiveDigest(active) + } if err == nil { err = ctx.Err() } @@ -155,6 +158,17 @@ func (e *Engine) refreshSpecSearch(ctx context.Context, force bool) error { } return err } + e.gate.Lock() + if e.index != index { + // Aggregate consolidation can replace the pointer without changing + // authority or setting dirty. Retry the deferred digest on that snapshot. + e.specDirty.Store(true) + } else if index.specDigest == "" { + next := *index + next.specDigest = digest + e.index = &next + } + e.gate.Unlock() e.specStatus.Store(&specSearchStatus{digest: digest}) return nil } diff --git a/internal/publishing/spec_search_backend_test.go b/internal/publishing/spec_search_backend_test.go index 6c283cd..3bddf36 100644 --- a/internal/publishing/spec_search_backend_test.go +++ b/internal/publishing/spec_search_backend_test.go @@ -212,3 +212,52 @@ func TestSpecSearchOverlappingRefreshPreservesNewInstall(t *testing.T) { t.Fatalf("new snapshot result=%+v err=%v diagnostics=%+v dirty=%v", result, err, d, e.specDirty.Load()) } } + +func TestSpecSearchMachineRefreshOverlapConvergesWhenWorkerIsIdle(t *testing.T) { + e, r := workerRefreshFixture(t) + r.sources = r.sources[:1] + if worked, err := e.processOne(t.Context()); err != nil || !worked { + t.Fatalf("install Product: worked=%v err=%v", worked, err) + } + started, release := make(chan struct{}), make(chan struct{}) + t.Cleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + var block atomic.Bool + block.Store(true) + e.specBleve.resources = platform.NewResourceGate(func(string) (platform.ResourceStats, error) { + if block.Swap(false) { + close(started) + <-release + } + return platform.ResourceStats{VolumeID: "specs", TotalBytes: 1 << 40, FreeBytes: 1 << 40}, nil + }) + done := make(chan error, 1) + go func() { done <- e.refreshSpecSearch(t.Context(), false) }() + select { + case <-started: + case <-time.After(10 * time.Second): + t.Fatal("refresh did not reach resource admission") + } + // Aggregate convergence installs a new pointer with the same public + // authority. It must not consume the deferred digest's only retry signal. + if err := e.refreshMachine(t.Context()); err != nil { + t.Fatal(err) + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if worked, err := e.processWorkerBatch(t.Context()); err != nil || worked { + t.Fatalf("idle worker: worked=%v err=%v", worked, err) + } + result, err := e.SearchSpecs(t.Context(), specBackendRequest(t)) + d := e.SpecSearchDiagnostics() + if err != nil || result.Total != 2 || !d.Ready || d.CandidateQueries != 1 || d.FallbackQueries != 0 { + t.Fatalf("idle convergence total=%d err=%v diagnostics=%+v", result.Total, err, d) + } +} diff --git a/internal/publishing/worker_refresh_test.go b/internal/publishing/worker_refresh_test.go index d2ab080..6ba3555 100644 --- a/internal/publishing/worker_refresh_test.go +++ b/internal/publishing/worker_refresh_test.go @@ -61,7 +61,20 @@ func (r *workerRefreshRepository) ActivatePublication(ctx context.Context, _ Int defer coordinator.UnlockVisibility() a.VisibilityGeneration = int64(len(r.active) + 10) r.active = append(r.active, a) - return true, coordinator.InstallVisibility(ctx) + return true, installTestProduct(ctx, coordinator, a.ProductID) +} + +func (r *workerRefreshRepository) ActivePublication(_ context.Context, id string) (ActivePublication, bool, error) { + for _, a := range r.active { + if a.ProductID == id { + return a, true, nil + } + } + return ActivePublication{}, false, nil +} + +func (r *workerRefreshRepository) ActiveProductRouteHistory(context.Context, string) ([]RouteHistory, error) { + return nil, nil } func (r *workerRefreshRepository) FailPublicationIntent(context.Context, string, string) error { diff --git a/internal/storage/sqlite/publication_store.go b/internal/storage/sqlite/publication_store.go index 55a90ac..05f5e1d 100644 --- a/internal/storage/sqlite/publication_store.go +++ b/internal/storage/sqlite/publication_store.go @@ -771,7 +771,7 @@ func (s *Store) ActivatePublication(ctx context.Context, intent publishing.Inten return false, err } activated := false - err = s.withVisibilityTransaction(ctx, coordinator, func(tx *sql.Tx) error { + err = s.withProductVisibilityTransaction(ctx, coordinator, intent.ProductID, func(tx *sql.Tx) error { var revision int64 var recordState catalog.RecordState var status catalog.Status @@ -884,12 +884,34 @@ func (s *Store) FailPublicationIntent(ctx context.Context, intentID, message str } func (s *Store) ActivePublications(ctx context.Context) ([]publishing.ActivePublication, error) { - rows, err := s.db.QueryContext(ctx, `SELECT a.product_id,a.source_revision,a.public_revision,a.site_epoch,a.route, + return s.activePublications(ctx, "") +} + +// ActivePublication uses the same authoritative visibility predicate as the +// full startup read, but attaches content metadata for only one Product. +func (s *Store) ActivePublication(ctx context.Context, productID string) (publishing.ActivePublication, bool, error) { + if productID == "" { + return publishing.ActivePublication{}, false, errors.New("Product ID required for visibility read") + } + activations, err := s.activePublications(ctx, productID) + if err != nil || len(activations) == 0 { + return publishing.ActivePublication{}, false, err + } + return activations[0], true, nil +} + +func (s *Store) activePublications(ctx context.Context, productID string) ([]publishing.ActivePublication, error) { + query := `SELECT a.product_id,a.source_revision,a.public_revision,a.site_epoch,a.route, a.artifact_id,a.manifest_hash,a.visibility_generation,a.view_json,a.activated_at FROM public_activations a JOIN public_visibility v ON v.product_id=a.product_id JOIN products p ON p.id=a.product_id - WHERE v.visible=1 AND v.generation=a.visibility_generation AND p.record_state='current' AND p.status='published' - ORDER BY a.route,a.product_id`) + WHERE v.visible=1 AND v.generation=a.visibility_generation AND p.record_state='current' AND p.status='published'` + var args []any + if productID != "" { + query += ` AND a.product_id=?` + args = append(args, productID) + } + rows, err := s.db.QueryContext(ctx, query+` ORDER BY a.route,a.product_id`, args...) if err != nil { return nil, err } @@ -1090,8 +1112,48 @@ func (s *Store) ConvergePublicationDependencies(ctx context.Context, productID s }) } +// A restart rebuilds every aggregate from the current admitted set. Retain the +// dirty watermarks for revoked Products too, since they have no active unit +// whose revision could otherwise complete their durable dependency work. +func (s *Store) RevokedPublicationDependencies(ctx context.Context) (map[string]int64, error) { + rows, err := s.db.QueryContext(ctx, `SELECT d.entity_id,MAX(d.desired_revision) + FROM publication_dirty d JOIN products p ON p.id=d.entity_id + WHERE p.record_state='archived' OR p.status='hidden' GROUP BY d.entity_id`) + if err != nil { + return nil, err + } + defer rows.Close() + revisions := make(map[string]int64) + for rows.Next() { + var id string + var revision int64 + if err := rows.Scan(&id, &revision); err != nil { + return nil, err + } + revisions[id] = revision + } + return revisions, rows.Err() +} + func (s *Store) ActiveRouteHistory(ctx context.Context) ([]publishing.RouteHistory, error) { - rows, err := s.db.QueryContext(ctx, `SELECT route,product_id,target_route,state,site_epoch FROM public_route_history ORDER BY route`) + return s.activeRouteHistory(ctx, "") +} + +func (s *Store) ActiveProductRouteHistory(ctx context.Context, productID string) ([]publishing.RouteHistory, error) { + if productID == "" { + return nil, errors.New("Product ID required for route history read") + } + return s.activeRouteHistory(ctx, productID) +} + +func (s *Store) activeRouteHistory(ctx context.Context, productID string) ([]publishing.RouteHistory, error) { + query := `SELECT route,product_id,target_route,state,site_epoch FROM public_route_history` + var args []any + if productID != "" { + query += ` WHERE product_id=?` + args = append(args, productID) + } + rows, err := s.db.QueryContext(ctx, query+` ORDER BY route`, args...) if err != nil { return nil, err } @@ -1177,7 +1239,7 @@ func (s *Store) QueuePublicationRepair(ctx context.Context, activation publishin } func (s *Store) RevokeProduct(ctx context.Context, request publishing.RevokeRequest, coordinator publishing.VisibilityCoordinator) error { - return s.withVisibilityTransaction(ctx, coordinator, func(tx *sql.Tx) error { + return s.withProductVisibilityTransaction(ctx, coordinator, request.ProductID, func(tx *sql.Tx) error { if err := requireActorCapability(ctx, tx, request.ActorID, identity.CapabilityCatalogEdit); err != nil { return err } @@ -1311,6 +1373,10 @@ func saveRouteHistory(ctx context.Context, tx *sql.Tx, productID, oldRoute, targ } func (s *Store) withVisibilityTransaction(ctx context.Context, coordinator publishing.VisibilityCoordinator, apply func(*sql.Tx) error) error { + return s.withProductVisibilityTransaction(ctx, coordinator, "", apply) +} + +func (s *Store) withProductVisibilityTransaction(ctx context.Context, coordinator publishing.VisibilityCoordinator, productID string, apply func(*sql.Tx) error) error { if coordinator == nil { return errors.New("visibility coordinator required") } @@ -1333,7 +1399,15 @@ func (s *Store) withVisibilityTransaction(ctx context.Context, coordinator publi if err := tx.Commit(); err != nil { return err } - if err := coordinator.InstallVisibility(context.WithoutCancel(ctx)); err != nil { + install := coordinator.InstallVisibility + if productID != "" { + if incremental, ok := coordinator.(interface { + InstallProductVisibility(context.Context, string) error + }); ok { + install = func(ctx context.Context) error { return incremental.InstallProductVisibility(ctx, productID) } + } + } + if err := install(context.WithoutCancel(ctx)); err != nil { return fmt.Errorf("visibility state committed but public index installation failed: %w", err) } return nil