Give chwriter.Registry periodic refresh, matching Tantivy's tracker

Closing search's active-tenant gap last commit surfaced a real asymmetry
by comparison: chwriter.Registry's per-tenant writer map was still a
snapshot built once at enterprise-ingest startup with no refresh at all,
while search's new ActiveTenantTracker refreshes every minute. A tenant
deprovisioned after enterprise-ingest started would keep writing
successfully to ClickHouse until the next restart -- a real, disclosed
staleness gap, not matched by anything on the Tantivy side anymore.

Registry.StartRefreshing spawns a goroutine that re-lists active tenants
every minute (dataSourceRefreshInterval, same interval as search's
tracker) via a new SourceLister callback and reconciles the writer map:
opens a connection for a newly-active tenant, closes and removes one no
longer active. New connections are dialed before taking the write lock,
so a slow/unreachable ClickHouse for one newly-active tenant never
blocks WriteBatch's read lock. A refresh failure (lister error, or one
tenant's connection failing to open) logs and leaves the existing map
untouched for that tick -- the same last-known-good posture
ActiveTenantTracker already uses, so a transient rbacstore/Postgres blip
doesn't evict every other tenant's already-working writer.

WriteBatch now takes a read lock and Close takes a write lock -- the
writer map was safe unsynchronized before only because it was immutable
after New() returned; StartRefreshing makes it mutable at runtime.

enterprise-ingest/main.go extracts the existing rbacstore-row-to-
DataSource adaptation into tenantDataSourceLister, reused for both the
initial synchronous load and StartRefreshing's periodic calls, so the
two can't drift into checking different things.

Verified: the lister-error-keeps-last-known-good path is Docker-free
(same "construct a Registry directly, bypass New" trick the existing
fail-closed tests use). The actual add/remove reconciliation against
real ClickHouse connections (TestRefreshAddsNewlyActiveTenant,
TestRefreshRemovesNoLongerActiveTenant) are skip-gated live-ClickHouse
tests, same CHWRITER_TEST_CLICKHOUSE_ADDR convention as this package's
existing integration tests -- not run against a live database in this
environment.

This closes the last disclosed gap from Phase 4's write-routing work:
both storage engines now share the same one-minute active-tenant
staleness bound instead of one being materially staler than the other.
This commit is contained in:
2026-08-14 23:55:40 -07:00
parent 088677643f
commit 2e8ab1ed6a
7 changed files with 379 additions and 90 deletions
+42 -12
View File
@@ -45,6 +45,12 @@ import (
"github.com/sentry/sentry/ingest/consumer"
)
// dataSourceRefreshInterval matches search/src/tenants.rs's
// REFRESH_INTERVAL -- both close the same disclosed asymmetry
// (/docs/security/threat-model.md's "Read this first") on their
// respective storage engines, so they use the same staleness bound.
const dataSourceRefreshInterval = time.Minute
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
@@ -80,22 +86,14 @@ func main() {
// that's mid-provisioning simply has no writer in the registry
// below, so chwriter.Registry.WriteBatch refuses it the same way
// chrunner.Registry.RunSQL already refuses an unprovisioned tenant
// on the read side.
sources, err := rbac.ListProvisionedDataSources(ctx)
// on the read side. lister is reused below for periodic refresh,
// not just this one startup call.
lister := tenantDataSourceLister(rbac)
chwSources, err := lister(ctx)
if err != nil {
logger.Error("listing provisioned data sources", "error", err)
os.Exit(1)
}
chwSources := make([]chwriter.DataSource, 0, len(sources))
for _, s := range sources {
if s.ClickHouseUsername == nil || s.ClickHousePassword == nil {
continue // ListProvisionedDataSources already filters these out; defensive only.
}
chwSources = append(chwSources, chwriter.DataSource{
TenantID: s.TenantID, Database: s.ClickHouseDatabaseName,
Username: *s.ClickHouseUsername, Password: *s.ClickHousePassword,
})
}
logger.Info("loaded tenant data sources", "count", len(chwSources))
registry, err := chwriter.New(ctx, cfg.ClickHouseAddr, chwSources)
@@ -115,6 +113,13 @@ func main() {
srv := &http.Server{Addr: cfg.HTTPListenAddr, Handler: mux}
g, ctx := errgroup.WithContext(ctx)
// Closes the staleness gap disclosed in
// /docs/security/threat-model.md as an asymmetry with search's
// tenants.ActiveTenantTracker: the writer map built above was a
// startup-only snapshot until this call -- now it re-lists and
// reconciles every dataSourceRefreshInterval, stopping when ctx is
// cancelled (same shutdown path c.Run below uses).
registry.StartRefreshing(ctx, lister, dataSourceRefreshInterval, logger)
g.Go(func() error { return c.Run(ctx) })
g.Go(func() error {
logger.Info("enterprise-ingest healthz listening", "addr", cfg.HTTPListenAddr)
@@ -137,6 +142,31 @@ func main() {
}
}
// tenantDataSourceLister adapts rbacstore's row shape into
// []chwriter.DataSource -- shared between the initial synchronous load
// above (must succeed before this binary does anything) and
// chwriter.Registry.StartRefreshing's periodic re-list, so the two
// never drift into checking different things.
func tenantDataSourceLister(rbac *rbacstore.Store) chwriter.SourceLister {
return func(ctx context.Context) ([]chwriter.DataSource, error) {
sources, err := rbac.ListProvisionedDataSources(ctx)
if err != nil {
return nil, err
}
chwSources := make([]chwriter.DataSource, 0, len(sources))
for _, s := range sources {
if s.ClickHouseUsername == nil || s.ClickHousePassword == nil {
continue // ListProvisionedDataSources already filters these out; defensive only.
}
chwSources = append(chwSources, chwriter.DataSource{
TenantID: s.TenantID, Database: s.ClickHouseDatabaseName,
Username: *s.ClickHouseUsername, Password: *s.ClickHousePassword,
})
}
return chwSources, nil
}
}
// runHealthcheck mirrors every other binary in this repo's
// -healthcheck self-check mode -- execs the binary against itself
// rather than using an external tool (see e.g. api/cmd/api/main.go's