8000 feat(components): add the cluster ownership fence and DeclareClusterEpoch by VLukiantsev · Pull Request #1234 · google/xmc · GitHub
[go: up one dir, main page]

Skip to content

feat(components): add the cluster ownership fence and DeclareClusterEpoch - #1234

Merged
VLukiantsev merged 11 commits into
mainfrom
feat/components-ownership-fence-b548436300
Oct 16, 2026
Merged

VLukiantsev merged 11 commits into
mainfrom
feat/components-ownership-fence-b548436300

Conversation

@VLukiantsev
@VLukiantsev VLukiantsev commented Oct 13, 2026 •
Copy link
Copy Markdown
Collaborator

Summary

Adds the cluster ownership fence (migration 015) and the DeclareClusterEpoch RPC, so moving component ownership between clusters needs an explicit, reviewable epoch raise.

Motivation & Context

The design allows one writing cluster per CMS. A restored or rebuilt cluster, or a DR standby, must not silently take over components owned by another cluster.

Key Changes

  • Migration 015_component_ownership_fence (Spanner, mirrored for PostgreSQL in internal/storage/postgres/migrations/ess/ and ess-schema.sql): ComponentOwnershipFence with LastSeenTime NOT NULL and CK_ComponentOwnershipFence_Epoch CHECK (Epoch >= 1). CMS writes only FenceId=1; there is no singleton CHECK because FenceId is reserved for partitioned fences. ClusterIdentity gains name = 2.
  • DeclareClusterEpoch rejects a missing cluster, validates a canonical-UUID uid and a DNS-1123 name, and returns CREATED, REFRESHED (heartbeat; writes only LastSeenTime/name), RAISED, or TAKEOVER. It is a mutating RPC: only callers that pass AuthorizeMutating (the bridge service account or an admin) may declare; without an authorizer it returns Unauthenticated.
  • TAKEOVER (another cluster, higher epoch) moves every active owned component to the caller's cluster and epoch in one DML statement and clears the owner uid, setting SourceLastSeenTime = PENDING_COMMIT_TIMESTAMP() to start the stale-owner adoption grace period; the caller's next intent re-adopts each record under the same ComponentId (R2). Only the declaring cluster may adopt a cleared owner; the fenced-out cluster gets SOURCE_INCARNATION_MISMATCH (R6). The takeover is an owner-only write: generation, UpdateTime and etag are unchanged.
  • Rejections: CLUSTER_EPOCH_REGRESSED (active cluster, lower epoch) and CLUSTER_FENCED (another cluster, equal or lower epoch), with ErrorInfo metadata operation, active_cluster_uid, active_cluster_name, active_epoch.
  • Implemented in both stores (spanner_ownership_fence.go for Spanner); README documents the takeover's ceiling under Spanner's per-commit mutation limit and the authorization.

Stack Position

Assumptions

  • Size exception (owner-approved): 498 prod / 501 test / 999 total lines; prod is 98 lines (24.5%) over the 400-line threshold, total is under 1000. It was 407 before the rebase onto main; feat(components): enforce gRPC authentication and authorization on CMS handlers #1219's fail-closed authorizer required authorizing DeclareClusterEpoch (+15 prod), and the PostgreSQL mirror of migration 015 added 56 lines of DDL plus license headers to keep both schemas in sync. Review follow-ups added 20 more: named fence rejection metadata keys (+8) and the exported store.ErrorInfoReason decoder (+12), which replaces three test-local copies and a production copy upstack. More than 100 prod lines are license headers on new files plus migration DDL, and executable Go is about 270 lines. The layer is one capability (the fence end to end: proto → CMS server → Store → memory and Spanner backends → migration); the only available split is horizontal (memory first, Spanner second) and would need a stub with incomplete semantics.
  • One fence per CMS (FenceId=1) is enough for now — multi-cluster routing is deferred by the design — partitioned fences would reuse the reserved key.
  • A takeover of hundreds of components fits in one Spanner commit — rows × 4 columns stays under the mutation limit (ceiling about 20,000 rows); the ceiling is documented — far larger catalogs would need a batched takeover.
  • Migration number 015 is still free on main — if another lands first, it must be renumbered.
  • The live GKE evidence still applies to this revision — it was collected before the last rebases onto main and before the authorization change — if wrong, the checkpoint must be re-run before merge.

Verification & Testing

  • Commands Executed: bazel test //pkg/components/store:store_test //pkg/components/cms:cms_test //internal/storage/spanner/migrations:migrations_test //api:protos_breaking_test
  • Coverage & Test Status: all pass against the memory store and the Spanner emulator, including TestServer_AuthorizationEnforcement (nil authorizer → Unauthenticated, viewer → PermissionDenied, bridge SA → store) and TestServer_DeclareClusterEpochAuthorization.
  • KinD E2E: //test/e2e:components_lifecycle_test passed with the whole stack applied, using the real authorizer with the bridge service account.
  • Diff size (tools/analyze_diff_size.py, base = merge-base with main< 8000 /code>): 498 prod / 501 test / 999 total / 16 doc lines (see the size exception above).
  • Live evidence scope: the GKE checkpoint below predates the review and hardening fixes (cluster-scoped adoption of cleared owners, migration 015 constraints, takeover SourceLastSeenTime, active_cluster_uid) and the authorization; those are covered by the unit and Spanner-emulator tests above.

Live Deployment & Manual Testing

  • Deployment Target: Live GKE development environment (enable_components: true, Spanner-backed CMS), upgraded with the dev-environment upgrade script including Spanner migrations 014/015.
  • Cluster Health: Environment verification passed (5/5, 16/16 pods Ready, baseline Component CRs Ready and Synced).
  • Checkpoint: CP2/CP3, run with the whole stack deployed (through fix(components): reduce steady-state bridge log noise and use detail attribute #1244).
  • Revision note: the live evidence was collected before the latest rebases onto main.
Scenario Result Observation
Migration 015 and DeclareClusterEpoch ✅ PASS SchemaMigrations reached Version=15, Dirty=False. The fence row (FenceId=1) recorded the cluster's kube-system UID, its name and Epoch=1; a refresh returned OUTCOME_REFRESHED.
CLUSTER_FENCED, CLUSTER_EPOCH_REGRESSED, InvalidArgument ✅ PASS A standby cluster uid at epoch 1 got CLUSTER_FENCED; the active cluster at epoch 1 against fence epoch 2 got CLUSTER_EPOCH_REGRESSED; epoch=0 got InvalidArgument.

Related Issues / Bugs

Bug: 548436300

TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c


Stack created with GitHub Stacks CLI • Give Feedback 💬

@VLukiantsev
VLukiantsev added this pull request to stack #1245 October 13, 2026 07:27
@gemini-code-assist gemini-code-assist Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements an ownership fence mechanism to ensure single-cluster execution for writing component intents. It introduces the DeclareClusterEpoch RPC, updates the Spanner schema with a new ComponentOwnershipFence table, and implements the fencing and takeover logic in both the in-memory and Spanner store backends. The review feedback suggests several robustness and style improvements: explicitly validating that the cluster identity is not nil at the API boundary, defensively returning nil, err if the Spanner transaction fails, and refactoring the Spanner error check to be more idiomatic by verifying err != nil first.

Comment thread pkg/components/cms/server.go Resolved
@VLukiantsev
Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an ownership fence mechanism to ensure single-cluster execution for writing Component intents, adding the DeclareClusterEpoch RPC, database schema updates, and store implementations for both in-memory and Spanner backends. The review feedback highlights the need to update the component's UpdateTime (and Etag for the in-memory store) during a fence takeover to ensure consistency and correct Etag generation, as well as updating the documentation to reflect these column updates.

Comment thread pkg/components/store/memory_store.go Resolved
Comment thread pkg/components/README.md Resolved
@VLukiantsev
Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the 'Ownership Fence' mechanism to ensure single-cluster execution for writing Component intents. It adds the DeclareClusterEpoch RPC to the Component Manager Service, allowing a cluster to declare itself the active writer at a specific epoch. The implementation includes database schema updates (introducing the ComponentOwnershipFence table), in-memory and Spanner store implementations, and comprehensive tests verifying fence creation, heartbeats, epoch increments, and takeover logic where owned components are reassigned to a new active cluster. There are no review comments on this pull request, so I have no feedback to provide.

@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch 2 times, most recently from d872196 to dabe624 Compare October 14, 2026 09:37
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch from dabe624 to 7b3740d Compare October 14, 2026 09:49
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch 2 times, most recently from e69d54f to fa19b55 Compare October 14, 2026 11:58
@VLukiantsev
VLukiantsev removed this pull request from stack #1245 October 14, 2026 13:31
@VLukiantsev
VLukiantsev added this pull request to stack #1277 October 14, 2026 13:32
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch from fa19b55 to 7e0ab87 Compare October 14, 2026 13:54
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch 3 times, most recently from 7e930c9 to 4febb6d Compare October 15, 2026 08:07
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch 2 times, most recently from e92472b to fda8206 Compare October 15, 2026 09:29
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch from 3920066 to 2930d7e Compare October 15, 2026 15:43
@VLukiantsev VLukiantsev added the priority::2-high top of mind. should not wait. label Oct 15, 2026
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch from 2930d7e to 62e01a4 Compare October 16, 2026 08:53
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch 3 times, most recently from bb0e59b to 354c207 Compare October 16, 2026 11:27
@VLukiantsev
VLukiantsev marked this pull request as ready for review October 16, 2026 12:11
@github-actions
github-actions Bot requested a review from janzak-dev October 16, 2026 12:11
Base automatically changed from feat/components-source-ownership-check-b548436300 to main October 16, 2026 12:18
…poch

The ownership design allows one writing cluster per CMS (DD Stage 2
§3.4). A restored or rebuilt cluster, or a DR standby, must not silently
take over components owned by another cluster. Moving ownership needs an
explicit, reviewable step: raising a GitOps-declared epoch.

Migration 015 adds the singleton ComponentOwnershipFence table
(FenceId 1, reserved for partitioned fences later). The new
DeclareClusterEpoch RPC takes a ClusterIdentity, which gains name = 2.
The uid must be a canonical UUID (the kube-system Namespace UID) and
the name a DNS-1123 label. The RPC updates the fence in one read-write
transaction, as planDeclaration decides, and reports the outcome:

- No fence yet: CREATED. The caller becomes active; the first owner
  keeps it.
- Active cluster, same epoch: REFRESHED. A heartbeat that writes only
  LastSeenTime and, if it changed, the name, so it takes no lock on the
  cells that intents read.
- Active cluster, higher epoch: RAISED (DeclaredTime moves).
- Active cluster, lower epoch: CLUSTER_EPOCH_REGRESSED.
- Another cluster, higher epoch: TAKEOVER. One DML statement moves
  every active owned component to the caller's cluster and epoch and
  clears the owner uid, keeping the namespace. The caller's next intent
  then adopts each component under the same ComponentId (R2, now
  explicit in checkSourceOwnership).
- Another cluster, equal or lower epoch: CLUSTER_FENCED. Two clusters
  that declare the same epoch cannot both win.

Rejections are FailedPrecondition with ErrorInfo metadata operation,
active_cluster_name and active_epoch. Both stores implement it: the
memory store under its lock, Spanner in a new
spanner_ownership_fence.go. The README notes the takeover's ceiling
under Spanner's per-commit mutation limit.

Sourced intents do not check the fence yet; the next layer adds that,
and the two layers land together. The bridge starts declaring in a
later layer.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
Address review feedback on the ownership fence:
- Reject DeclareClusterEpoch requests without a cluster identity at the
  API boundary, return nil on a failed Spanner transaction, and check err
  before its NotFound code when reading the fence.
- Adopt an owner cleared by a takeover only from the declaring cluster, so
  a fenced-out cluster cannot take components back (R6 instead of R2).
- Add a singleton CHECK on ComponentOwnershipFence and sync the README
  ownership table, fence claims and ClusterIdentity.uid comment.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
Assert that a fence takeover leaves the generation, UpdateTime and etag of
reassigned components unchanged on both stores, and document why: the
owner is not part of the Component resource.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
…dateRequestSource

ValidateClusterIdentity now reuses validateClusterUID and
validateDNS1123Label, introduced by the #1233 hardening for
ValidateRequestSource, so declared clusters and request sources apply
the same identity checks. Messages are unchanged.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
…over grace

Migration 015 now declares LastSeenTime NOT NULL and
CK_ComponentOwnershipFence_Epoch CHECK (Epoch >= 1), and drops the FenceId
singleton CHECK so FenceId stays reserved for partitioned fences.

A takeover sets SourceLastSeenTime to PENDING_COMMIT_TIMESTAMP(), starting
the owner_stale adoption grace period, and fence rejections add
active_cluster_uid to their ErrorInfo metadata.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
DeclareClusterEpoch can take over every component a cluster owns, so it now goes through the mutating-RPC authorization that #1219 added: only the configured controller service account (the bridge) or a platform admin may call it, and a server without an authorizer fails closed. The authorization check moves into Server.authorizeMutating, shared with validateMutatingRequest.

TestServer_AuthorizationEnforcement now covers DeclareClusterEpoch, and a new test checks that a denied declaration leaves no fence behind. TestCMSServer_DeclareClusterEpoch configures the disabled authorizer like the other server tests.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
Add PostgreSQL migration 015_component_ownership_fence and the ComponentOwnershipFence table to the canonical PostgreSQL ESS schema, mirroring the Spanner migration.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
…nce test assertions

fenceError uses metadataKeyOperation, operationDeclareClusterEpoch and
metadataKeyActive* constants instead of string literals. The fence tests
check the status code and the ErrorInfo details separately.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
Describe how a second cluster can reach the same CMS (rebuild, disaster
recovery, migration, misconfiguration), why source ownership alone locks
such a cluster out, and that the fence is a handover with one active
writer, not multi-cluster support. Drop an internal design reference.

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
@VLukiantsev
VLukiantsev force-pushed the feat/components-ownership-fence-b548436300 branch from b2f33e9 to c2f0729 Compare October 16, 2026 12:18
janzak-dev
janzak-dev previously approved these changes Oct 16, 2026
…orInfoReason

Export store.ErrorInfoReason next to ErrorInfoDomain and errorWithInfo,
and use it in place of three test-local copies: reasonOf in store,
errorInfoReason in store_test (which also skipped the domain check), and
errorInfoReason in cms. Callers match only on the ErrorInfo reason, and
the store package already exports the reasons and metadata keys, so the
decoder belongs with them; production callers upstack can use it too.

Comment link: #1234 (comment)

Bug: 548436300
TAG=agy
CONV=d833b0e6-4821-48f6-8576-776d53f55a5c
@github-actions github-actions Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Incremental PR Approval

PR author is a code owner and changes since last approval (13 lines, excluding 65 test file lines) are within the 50-line grace delta.

Preserving prior approval by @janzak-dev on commit c2f0729.

@VLukiantsev
VLukiantsev added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit 8ee3746 Oct 16, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority::2-high top of mind. should not wait.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0