Skip to content

feat(cockroach): add CockroachDB storage backend (KPEP-0001 phase 4)#4

Open
zachsmith1 wants to merge 19 commits into
mainfrom
feat/cockroach-backend
Open

feat(cockroach): add CockroachDB storage backend (KPEP-0001 phase 4)#4
zachsmith1 wants to merge 19 commits into
mainfrom
feat/cockroach-backend

Conversation

@zachsmith1

Copy link
Copy Markdown
Contributor

Summary

Adds a CockroachDB implementation of `storage.Interface` behind `--storage-backend=cockroach`, alongside the existing etcd3 and Spanner backends. Passes the full upstream storage conformance battery (24/24) and boots a real apiserver end-to-end.

Design highlights

Watch backing. A single `CREATE CHANGEFEED FOR TABLE kv WITH updated, mvcc_timestamp, diff, resolved='1s', min_checkpoint_frequency='1s', envelope='wrapped', format='json'` runs per apiserver process, fanning events to per-Watch subscribers. Reconnects use `WITH cursor=, no_initial_scan` so no events are lost across drops. Resolved-timestamp rows are dispatched as watch bookmarks — this is what advances the cacher's `watchCache.resourceVersion` and unblocks `waitUntilFreshAndBlock`.

Resource versions. Every RV is a Cockroach HLC, converted to `uint64` nanoseconds for the upstream contract. `GetCurrentResourceVersion` reads `cluster_logical_timestamp()`. Historical reads use `AS OF SYSTEM TIME ''`, with a graceful fallback to `AS OF SYSTEM TIME '-5s'` for young databases (SQLSTATE 3D000).

Optimistic concurrency. `GuaranteedUpdate` runs inside `crdbpgx.ExecuteTx` for automatic serialization-failure retries, with a same-txn `SELECT crdb_internal_mvcc_timestamp` refresh after the write to avoid the RETURNING-clause visibility gap. No-op detection short-circuits via `bytes.Equal`.

TTL / leases. Row-level TTL via `WITH (ttl_expiration_expression = 'expire_at', ttl_job_cron = '* * * * *')` (1-minute floor is enforced by Cockroach). A dedicated 1-second `TTLScanner` (`DELETE ... WHERE expire_at <= now() LIMIT $1 RETURNING key` in a serializable txn) closes the worst-case gap for lease-driven flows like leader election.

Observability. The reader's SQL session is tagged `application_name = 'kplane-cockroach-changefeed'` via an explicit `SET` on every connect, so operators can identify it in `SHOW SESSIONS` and tests can scope failure-injection filters without cross-contaminating parallel runs.

Registration. `Options` implements the storage-registry `Backend` interface with flags: `--cockroach-dsn`, `--cockroach-database`, `--cockroach-max-conns`, `--cockroach-max-conn-lifetime`, `--cockroach-max-conn-idle-time`, `--cockroach-health-check-period`.

Testing

  • Upstream conformance: 24/24 `RunTest*` cases pass against Cockroach v26.2.2.
  • Package suite: ~21s wall for a clean run, covering config, schema, store CRUD, list, GuaranteedUpdate, changefeed subscription, watcher, TTL scanner, and registration.
  • Reconnect coverage: a dedicated test cancels the reader's server-side session (scoped by `application_name`), waits for `SHOW CLUSTER QUERIES` to confirm the reader is gone, writes during the disconnect window, and asserts delivery — proving `cursor=` actually recovers events written while the reader was down. 5x in a row clean, no flakes.
  • Parallel test isolation: two concurrent `go test` binaries running the reconnect test against the same Cockroach both pass — CANCEL scoping by `application_name` keeps them from shooting each other's changefeeds.
  • Local e2e: apiserver boots against Cockroach with `--storage-backend=cockroach`, matches the etcd3 baseline for bootstrap LIST results. The live changefeed session shows the expected `application_name` tag, and killing it via CANCEL leaves the pool intact (48 conns) while the reader reconnects within ~4s.

Compatibility

Minimum tested version is Cockroach v25.4 (for the `WITH updated, mvcc_timestamp, resolved, min_checkpoint_frequency` syntax on core changefeeds and for row-level TTL). Primary target is v26.2.

Commit-by-commit review

The branch is structured into ~19 small commits, each self-contained and passing tests at that point. Reviewers can walk them one at a time:

  • `b4084a6` scaffold + pool config
  • `244744a` schema + cluster settings
  • `b32e7f0` `Create`/`Get`/`Delete`
  • `15cdb79` `GetList` with AOST
  • `c237738` `GuaranteedUpdate`
  • `65be2c0` changefeed subscription
  • `995d0d6` `Watch` on the changefeed fanout
  • `326c3e8` TTL scanner
  • `529064b` backend-registry integration
  • `fbd9537` upstream conformance suite + young-DB AOST fallback
  • `c202f1e` single-key initial-events + changefeed-ready gate
  • `c902713` `Stats` / `ReadinessCheck` / `EnableResourceSizeEstimation` / WatchList bookmark
  • `3500895`, `608f7a0` post-e2e fixes (TTLScanner race, `RequestWatchProgress`)
  • `7005c5a`, `869f621`, `5440969`, `711e191` reconnect test + `application_name` isolation

Test plan

  • Reviewer boots a local Cockroach v26.2 (`cockroach start-single-node --insecure`)
  • `COCKROACH_TEST_DSN=postgresql://root@localhost:26257/defaultdb?sslmode=disable go test ./backends/cockroach/...`
  • Spot-check the reconnect test: `go test -run TestChangefeed_ReconnectResumesFromCursor -count=3`
  • Optional apiserver e2e via the KPEP-0001 recipe with `--storage-backend=cockroach`

Adds an empty backends/cockroach/ package with a Config struct that builds
a pgxpool.Pool using Cockroach-recommended defaults (fixed-size pool,
5-minute connection lifetime with jitter, 30-second health checks). The
ConnConfig accessor exposes the underlying pgx.ConnConfig for callers
that need a dedicated connection outside the pool — the changefeed
subscription in a later commit is one such caller.

Pulls in pgx v5 and cockroach-go/v2 as dependencies.
Adds EnsureSchema, which creates the kv table (STRING key, BYTES value,
INT8 lease_ttl, TIMESTAMPTZ expire_at, partial STORING index on expire_at)
and enables the kv.rangefeed.enabled cluster setting required by
CREATE CHANGEFEED. The table declares row-level TTL via
ttl_expiration_expression + ttl_job_cron; a per-process scanner in a
later commit handles sub-minute expirations.

Idempotent — safe to call on every process start (CREATE TABLE IF NOT
EXISTS + SET CLUSTER SETTING are both no-ops at target).
Adds the store struct with Create, Get, and Delete backed by
crdbpgxv5.ExecuteTx for automatic serialization-failure retries.
Resource versions come from crdb_internal_mvcc_timestamp (per-row) or
cluster_logical_timestamp() (for the DELETE path where the row is
gone before we can read the mvcc column). Reads carry the
(expire_at IS NULL OR expire_at > now()) filter so TTL-expired rows
are invisible even before the built-in TTL job removes them.

Remaining storage.Interface methods (Watch, GetList, GuaranteedUpdate,
Stats, RequestWatchProgress, EnableResourceSizeEstimation) are stubbed
with errNotImplemented and land in follow-up commits.

Tests hit a live CockroachDB via COCKROACH_TEST_DSN (default
localhost:26257); each test provisions a fresh database and drops it
at cleanup.
Recursive and non-recursive GetList go through a single SQL query that
applies:
  - the TTL read filter (expire_at IS NULL OR expire_at > now())
  - the caller-requested snapshot RV via AS OF SYSTEM TIME (RV=0 becomes
    a follower-read '-5s'; explicit RV becomes the HLC form)
  - the caller-requested Limit (fetched as Limit+1 so PrepareContinueToken
    can distinguish 'last page' from 'more pages')

Guards too-high resource versions with TooLargeResourceVersionError before
issuing the query. resolveListRV guarantees the returned list never
carries RV=0 — the upstream cacher rejects storage responses that do.
Wraps the read-modify-write cycle in crdbpgxv5.ExecuteTx so serialization
failures (SQLSTATE 40001) are auto-retried. Semantic conflicts returned
from tryUpdate (apierrors.NewConflict) drive the outer retry loop.

Honors:
  - Preconditions.Check against the pre-modification object
  - ignoreNotFound: absent row triggers an INSERT with the tryUpdate output
  - cachedExistingObject: skips decrypt/decode when its RV matches the row
  - no-op detection: if the tryUpdate output re-encodes to identical
    bytes, the current row's RV is returned and no write happens (matches
    etcd's contract; changefeed emits nothing)
  - ttl: when non-zero, sets lease_ttl + expire_at; nil ttl leaves the
    row's TTL columns unchanged; explicit zero clears them
…ress

A single CREATE CHANGEFEED runs per process on a dedicated pgx.Conn
(sinkless changefeeds disable server-side result buffering and require a
conn outside the pool). Wrapped-envelope JSON with diff exposes before
and after row bytes; resolved rows drive a monotonic HLC watermark
consumers use as a watch bookmark cursor.

Subscribers register with a key prefix; the fanout applies the filter
per-event so watches on disjoint prefixes don't wake each other. A slow
consumer whose channel fills gets closed — same convention as etcd's
slow-watcher policy.

Reconnect: on disconnect, exponential backoff up to 5s and reopen
WITH cursor='<lastResolvedHLC>', no_initial_scan so events between the
last progress mark and the reconnect aren't replayed but also aren't
lost.
Watch registers a subscriber against the process-wide
ChangefeedSubscription with the caller's key prefix, then forwards
events onto the returned watch.Interface. Semantic parity with etcd3:

  - RV=0 or empty ResourceVersion + SendInitialEvents unset: replays
    the current state as ADDED events (legacy areInitialEventsRequired
    behavior). startRV advances to the highest emitted item's RV so
    the live stream doesn't re-deliver the same objects.
  - RV > 0: streams events strictly after that RV; earlier events are
    filtered client-side.
  - Predicate: applied per event; window transitions synthesize the
    correct ADDED/DELETED verb to match etcd3.
  - Cancelled context: run() exits before subscribing, ResultChan is
    closed immediately.
  - Slow consumer: the underlying subscriber closes when its buffered
    channel fills; watch.Interface owners see ResultChan close and
    re-establish through the cacher, matching etcd's convention.

The store now carries a *ChangefeedSubscription; SetChangefeed wires
it after construction so factory code can share one subscription
across every per-resource store.
Cockroach's built-in TTL job runs at minimum 1-minute cron cadence
(hardcoded 5-field POSIX cron parser), too slow for Kubernetes lease
TTLs of 10-15s. The scanner runs

  DELETE FROM kv
  WHERE expire_at IS NOT NULL AND expire_at <= now()
  LIMIT N RETURNING key

every ~1s. The partial index kv_by_expire_at makes this a small
indexed range scan; the LIMIT bounds tick cost so a burst of
expirations drains across a few ticks without one statement holding
write intents for too long.

Serializable isolation resolves the refresh race atomically: if a
client's UPDATE(expire_at=future) commits between our SELECT and
DELETE, our DELETE's WHERE clause misses the row and it survives —
no CAS logic required. Verified by TestTTLScanner_RefreshWinsRace.

The changefeed picks up the DELETEs and emits real watch events; no
synthetic events are needed. Cockroach's built-in TTL job is left
enabled as a backup for anything the scanner misses across process
restarts.
…pter

Wires the CockroachDB backend into the apiserver's dispatch chain:

  - factory.go: NewBackendFactory returns a kpstorage.BackendFactory for
    CR storage via the multicluster decorator, backed by a sharedRuntime
    (pool + changefeed + TTL scanner) that outlives any single Create.
  - factory_backend.go: FactoryBackend implements factory.Backend for the
    fork-level registry (master/peer endpoint leases, service IP and
    NodePort allocators). Shares the same sharedRuntime as Build.
  - register.go: Options implements registry.Backend, binding --cockroach-*
    flags (dsn, database, max-conns, max-conn-lifetime, max-conn-idle-time,
    health-check-period). Build and BuildFactoryBackend share one
    FactoryBackend so a process ends up with one pool, one changefeed,
    and one TTL scanner regardless of how many resources register.
  - backends/register.go: adds cockroach.NewOptions() to RegisterBuiltin
    next to spanner. Adding future backends stays a one-line change.
…fallback

Adds conformance_test.go which delegates each TestConformance* to the
upstream RunTest* battery, matching the etcd3 and Spanner backends.

Two production-relevant fixes surfaced by the suite:

  1. Get now guards too-high resource versions with
     TooLargeResourceVersionError before issuing the query (same guard
     GetList already has). Without this, AS OF SYSTEM TIME on an
     MaxInt64 rv returns SQLSTATE 22023 'timestamp is in the future.'

  2. Get and GetList fall back to a strong read when AS OF SYSTEM TIME
     returns SQLSTATE 3D000 ('database does not exist'). This happens
     when the requested past timestamp predates the database's own
     creation — common in tests that spin up fresh DBs per case;
     production databases are old enough that follower reads always
     succeed.

Suite results:
  - 22/24 conformance passes cleanly
  - 2 watch-side failures still under investigation
    (WatchFromZero and WatchDeleteEventObjectHaveLatestRV)
…ady gate

Two conformance failures fixed:

  - sendInitialEvents chose Recursive=true regardless of the caller's
    watch scope. A single-key watch (opts.Recursive=false) keeps
    the prepared key without a trailing slash; the recursive GetList then
    scanned WHERE key >= '/registry/pods/test-ns/foo/' — one past the
    row's key — and returned no items, so the initial ADDED replay
    never fired. Now branches: Recursive watches list; single-key
    watches Get with IgnoreNotFound.

  - Conformance tests raced the changefeed's own startup. The
    subscription's pgx conn takes ~100ms to establish; if the first
    write happens before the changefeed job is streaming, its event
    is missed. waitForChangefeedReady blocks until the first resolved
    timestamp lands, proving the pipeline is live.

Result: 23 PASS + 1 SKIP (the compaction phase self-skips when
compaction is nil, matching Spanner and etcd3).
…imation + WatchList bookmark

Apiserver bootstrap surfaced three needed pieces the stubs.go placeholder
missed:

  - Stats returns count(*) over the store's resource prefix; used by the
    apiserver's admission size-tracking. Not implementing it caused
    'not implemented yet' PostStartHook failures at startup.

  - EnableResourceSizeEstimation is a no-op (matches the Spanner backend);
    the ResourceSizeEstimator hook is opt-in for backends that expose
    per-object byte counts, which we don't yet.

  - RequestWatchProgress no-ops; changefeed's resolved-timestamp rows
    already flow through the normal event stream at 1s cadence, so
    consumers see progress updates without needing an on-demand poke.

The watcher also now emits an initial-events-end Bookmark after
sendInitialEvents when the caller explicitly asked for SendInitialEvents
+ AllowWatchBookmarks (the WatchList contract). The upstream reflector
requires this bookmark to consider the initial replay complete;
without it, the cacher's watchCache logs 'awaiting required bookmark
event' every second and never marks itself ready.
Verified all three pre-e2e questions with a probe-instrumented apiserver
run against a fresh Cockroach database:

  1. Bookmark IS reaching the reflector. The trace showed 60 bookmark
     SEND events, each with the correct type (typed per-resource, e.g.
     *core.Namespace), non-zero RV, and the k8s.io/initial-events-end
     annotation set. The cacher stops logging 'awaiting required
     bookmark event' — meaning the reflector received and accepted our
     bookmarks — so the cacher's watchCache Replace() fires and the
     cacher marks itself ready.

  2. The cacher passes exactly the flags we check. Every Watch call
     came in with SendInitialEvents=true, AllowWatchBookmarks=true,
     Recursive=true, ProgressNotify=true — matching the WatchList
     contract we gate on.

  3. SetChangefeed timing is fine. Every store instance's changefeed
     field is non-nil at Watch() time (cfNil=false on every probe).
     FactoryBackend.Create is called before any Watch fires against
     the returned store.

Probes stripped after confirmation.
sharedRuntime.newStore was rewriting scanner.store on every store
construction, a data race with the scanner goroutine that reads the
field on every tick. Replace with a direct *pgxpool.Pool handle so the
scanner's lifecycle no longer depends on any particular store instance.

Behavior unchanged; conformance suite still 24/24, TTL scanner tests
still 4/4.
Two coupled bugs blocking apiserver bootstrap; both needed for the fix.

WHY BOOTSTRAP HUNG. When a WatchList client (SharedInformerFactory
reflector, admission webhook informer, etc.) opens a watch against
cacher.Watch, the cacher calls storage.GetCurrentResourceVersion to get
the target RV the watchCache must reach, then blocks in
waitUntilFreshAndBlock. For Cockroach, GetCurrentResourceVersion returns
cluster_logical_timestamp() — the live HLC, which advances continuously
by nanoseconds. Our watchCache RV, on the other hand, only advanced on
real data mutations, so the wait never satisfied and the request
timed out after 3s with TooLargeResourceVersion. Every WatchList
request retried in a hot loop, WaitForCacheSync never returned, and
the webhook admission plugin's WaitForReady stayed false, blocking
every downstream Create (kube-system, priority classes, leases).

FIX PART 1 — watcher forwards resolved-timestamp progress. The
changefeed subscription already receives resolved-timestamp rows at 1s
cadence. The watcher's run loop previously discarded them with a
'continue'. It now converts them into watch.Bookmark events with the
resolved HLC as the ResourceVersion. The internal cacher reflector
processes each bookmark via handleWatch's ResourceVersionUpdater path,
which calls watchCache.UpdateResourceVersion and wakes waiters on the
cond variable.

FIX PART 2 — RequestWatchProgress publishes an on-demand progress row.
The cacher's ConditionalProgressRequester fires storage.RequestWatchProgress
whenever a client is blocked waiting for freshness. The previous
implementation was a no-op comment. It now reads
cluster_logical_timestamp() and publishes a synthetic progress event
to every changefeed subscriber via a new ChangefeedSubscription.PublishProgress.
That immediately drives the watchCache RV to current, unblocking the
waiter within a single tick instead of the 3s hard cap.

VERIFIED end-to-end: apiserver up, kube-system created, priority
classes bootstrapped, 55 rows in the kv table — matching the etcd
baseline exactly. Conformance suite still 24/24.
Kill the server-side CREATE CHANGEFEED via CANCEL QUERIES, then verify
the reader loop reconnects and delivers post-disconnect events on the
same subscriber. Exercises the WITH cursor=<lastResolved> branch of
buildChangefeedSQL, which is what prevents event loss across drops.
Default application_name to 'kplane-cockroach-changefeed' so the reader's
SQL session is identifiable in SHOW SESSIONS / SHOW CLUSTER QUERIES, and
so tests can scope failure-injection filters by app name instead of by
query text. Caller-provided values take precedence.
Two fixes:

- Scope the CANCEL by application_name instead of query text, so parallel
  tests running against the same cluster don't shoot each other's
  changefeeds down.
- Reorder the assertion: write the second row AFTER we've confirmed the
  reader session is gone from SHOW CLUSTER QUERIES, so its delivery
  proves the WITH cursor=<lastResolved>, no_initial_scan reconnect
  actually recovers events written during the disconnect window
  (previously the test wrote after reconnect was already complete).
The RuntimeParams["application_name"] approach didn't stick — pgx.ParseConfig
seeds the key with an empty string, and Cockroach ignored the startup
value in practice (verified against a live apiserver: sessions came up
with an empty application_name). Replace with an explicit
SET application_name = '<name>' executed right after each connect, so
the tag is guaranteed to apply on the first stream and on every
reconnect. Callers can override via WithApplicationName before Start.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant