Make the shard manager's persistence compare-and-swap based across local and distributed backends - #3790
Conversation
✅ Deploy Preview for golemcloud canceled.
|
b8a5958 to
d68a3b3
Compare
|
@unblocked review this PR |
There was a problem hiding this comment.
1 issue found.
In reply to #3790 (comment)
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| check_state_for_write(shard_state)?; | ||
| let encoded = serialize(shard_state).map_err(ShardManagerError::SerializationError)?; | ||
|
|
||
| // etcd reports mod_revision 0 for a key that does not exist, so `prev_revision == |
There was a problem hiding this comment.
The comment on line 108 is truncated mid-sentence: // etcd reports mod_revision 0 for a key that does not exist, so \prev_revision ==`. It should explain that prev_revision == NO_REVISION(0) means the key must not exist, and theCompareOp::Equalagainst 0 enforces create-only semantics — mirroring the explanation in thedb.rsbackend'scompare_and_swap_state_in_tx`.
ad6b5ec to
58f812e
Compare
…d distributed backends
…close two test gaps
58f812e to
4922125
Compare
Breaking: the
|
| cargo-test-r run --package golem-worker-service --test '*' -- --nocapture --report-time $JUNIT_OPTS | ||
| RUST_LOG=debug cargo-test-r run --package golem-debugging-service --test '*' -- --report-time $JUNIT_OPTS | ||
| cargo-test-r run --package golem-shard-manager --test integration -- --nocapture --report-time $JUNIT_OPTS | ||
| cargo-test-r run --package golem-shard-manager --test integration -- --nocapture --test-threads=1 --report-time $JUNIT_OPTS |
There was a problem hiding this comment.
Why do we need this? It will probably slow down CI significantly.
Also if it is really needed, note that test-r has support for marking suites (modules) as sequential from code, which is better than "remembering" to pass the thread count config in cli.
vigoo
left a comment
There was a problem hiding this comment.
Just one concern about sequential test execution. Once that's resolved good to go
It's needed for the etcd dimension only: the fixture shares one etcd server per worker on the fixed STATE_KEY, and every store wipes that key on connect, so concurrent persistence tests see each other's writes as revision conflicts. Without any sequencing 10 of the 14 etcd tests fail. Moved it into code as sequential_suite!(persistence) and dropped the CLI flag; the loop tests in shard_management stay parallel. CI cost is nil, the whole binary runs in ~6.5s. |
Resolves GOL-446
Summary
Ticket 2 of the shard manager redesign. Today
RoutingTablePersistence::writeis an unconditional last-write-wins upsert: two shard managers writing the same state clobber each other and nothing detects it. Ticket 3 adds leader election, which is only sound if the persisted state carries a fencing token.This PR delivers that substrate:
RoutingTablePersistencetrait, so distributed deployments do not keep shard state in a per-service SQL database. The backend is selected at startup from config;ShardManagementonly ever sees the trait..expect(...)panics in the shard management loop: snapshot, write, and on any failure roll back and stop.Blast radius is
golem-shard-managerplus one new test component ingolem-test-framework. No proto, executor, orgolem-commonchange. Local mode (Postgres/SQLite) keeps working exactly as before apart from the write becoming conditional.What changed
The trait (
sharding/persistence/mod.rs).read()returns(ShardLeaseState, ExternalRevision);write(&state, prev_revision)stores only if the current revision is exactlyprev_revisionand returns the new one.NO_REVISION = 0means "nothing is stored", so a write at0is create-only and a stored state always carries>= 1. A stale token yieldsConcurrentModification, which is non-retriable: retrying the sameprev_revisioncan never succeed.The actor (
sharding/shard_management.rs).ShardManagementcarriesexternal_revision: Arc<Mutex<ExternalRevision>>, seeded from the startup read. Every mutation goes throughmutate_and_persist: snapshot, mutate, bump the domain revision, write guarded on the cached token, and on any error restore the snapshot and returnErr. That ends the worker task and the process, which restarts and re-reads. The write lock is held across the round-trip soGetRoutingTablecan never hand out a state that then rolls back, and every round-trip is bounded at 30s so a wedged store cannot hold that lock forever.SQL backend (
sharding/persistence/db.rs, migration004).shard_manager_stategainsrevision BIGINT NOT NULL. The write is one transaction: the compare-and-swap statement, then a wholesale rewrite of theexecutor_leasesandshard_assignmentsmirror tables. The mirror is a projection of the blob for inspection with plain SQL; the blob stays the source of truth, and a rejected CAS never touches the mirror.etcd backend (
sharding/persistence/etcd.rs). FixedSTATE_KEY = "/golem/shard-manager/state". Read returns the key'smod_revision; write is a transaction comparingmod_revision(STATE_KEY) == prev_revisionand putting on success. Endpoints must behttp://; auth and TLS are out of scope and are refused with a message naming the gap.Config (
config.rs).persistence: PersistenceConfig { Postgres, Sqlite, Etcd }replacesdb: DbConfig. Env keys areGOLEM__PERSISTENCE__TYPEandGOLEM__PERSISTENCE__CONFIG__*. The standalone binary refuses to start if anyGOLEM__DB__*variable is still set, because the rename otherwise failed open: figment layered the env over defaults, serde dropped the unknown keys, and a deployment that had not been updated started successfully on the default SQLite file with an empty routing table.Wiring (
lib.rs). Postgres and Sqlite run migrations, build the pool, and constructDbRoutingTablePersistenceandDbQuotaRepo, unchanged. Etcd constructsEtcdRoutingTablePersistenceandUnavailableQuotaRepo, which fails quota writes loudly and returns nothing for reads: quota has no durable store in distributed mode until ticket 7.Tests.
tests/persistence.rsruns a shared suite across asqlite/postgres/etcdmatrix (read on empty store, round-trip, stale-revision conflict, housekeep expiry, mirror consistency, deleted-row refusal).tests/shard_management.rscovers rollback on both persist sites, the lock held across the round-trip, first boot atNO_REVISION, and the non-conflict rollback arm.DockerEtcdis a new test-framework component.integration-tests-group5now runs with--test-threads=1, since the etcd dimension shares one container and one fixed key.CI.
etcd-client's build script needsprotoc; it is installed in the sharedsetup-rustaction and in thecrosscontainer for the aarch64 build.Deviations from the ticket, and why
"Replace
DbRoutingTablePersistencewith a trait-based layer." The trait already existed andDbRoutingTablePersistencewas already one implementation behindArc<dyn RoutingTablePersistence>. The real work was changing the trait's signature to carry a token, adding the etcd implementation, and adding config selection.The SQL write is two statements, not the ticket's single
UPDATE … WHERE revision = $prev. A loneUPDATEcannot insert on first boot. The obvious alternative, a guarded upsert, has an unguardedINSERTbranch:write(state, prev = 5)against a row that was deleted underneath the writer would silently resurrect it, while etcd refuses the same write. One trait cannot have two behaviours, so the branch happens in Rust:prev == 0runsINSERT … ON CONFLICT (id) DO NOTHING,prev > 0runs the guardedUPDATE, androws_affected == 0means conflict in both. The deleted-row case is real:integration-tests/tests/sharding.rsdeletes that row.Migration
004drops and recreates the table instead ofALTER TABLE … ADD COLUMN revision BIGINT NOT NULL DEFAULT 0. ADEFAULT 0row would be indistinguishable from "absent", since0is the sentinel, and the read path rejects a stored revision below1.003had already emptied the table, and SQLite'sADD COLUMNrules would otherwise leave the two dialects with different schemas. Consequence: a row written by a ticket-1 build is dropped at upgrade. No released version carries migration003, so no released deployment is affected. After the upgrade the routing table is empty until every executor process restarts; that belongs in the release notes.etcd returns
TxnResponse::header().revision(), not the ticket's follow-upGET. A transaction that applied a mutation reports the new store revision, and etcd stamps every key it wrote with exactly that value. TheGETis a wasted round-trip and a race: a rival write landing in between would be cached as ours, and the next CAS would compare equal against their write and overwrite it.etcd-client = "0.19.0", not"0.14". 0.19 matches the workspace's tonic/prost/http/tower versions, so it adds one package to the lockfile and reuses the already-compiled gRPC stack. 0.14 pulls tonic 0.12 and duplicates the whole tree.PersistenceConfiguses the repo's serde form,#[serde(tag = "type", content = "config")]with PascalCase tags (Postgres,Sqlite,Etcd) and the concreteDbPostgresConfig/DbSqliteConfigpayloads, rather than the ticket'srename_all = "snake_case"over a singleDbConfig. This matchesHealthCheckModeand the previousdbconfig, so generated TOML and env files keep their existing shape.Rollback covers every failure, not only
ConcurrentModification. The ticket's snippet bumps the revision with?and only rolls back around the write. Here the bump, the write, a backend error and the 30s timeout all restore the snapshot. Nothing is retried on conflict: the cached token is deliberately not refreshed, because a loser that re-reads and writes again is not fenced. Compare-and-swap is only a fence if the loser stops.Two revisions coexist and are never derived from each other. The in-blob
ShardLeaseRevisionis domain-owned and increments by one;ExternalRevisionis store-owned and opaque. Tying them would break CAS for any write that does not bump the domain revision, which ticket 4's lease renewals will be.Decisions the ticket deferred and this PR honours as deferrals: no leader election (ticket 3), quota in etcd mode (ticket 7), etcd auth and TLS (with ticket 3).
Verification
cargo-test-r run -p golem-shard-manager --lib: 85 passed.cargo-test-r run -p golem-shard-manager --test integration -- --test-threads=1: 56 passed across the sqlite/postgres/etcd matrix.kubernetesfeatures,cargo fmt,cargo make check-configs: clean..await?to.await.ok()turnsa_persistence_failure_after_the_rebalance_was_executed_stops_the_loopred; releasing the state lock before the round-trip turnsreaders_cannot_observe_a_state_that_is_still_being_persistedred; skipping the rollback for non-conflict errors turnsa_non_conflict_persistence_error_is_rolled_back_the_same_wayred while the conflict test stays green.revisionclimbing in SQL across passes,mod_revisionadvancing underetcdctl. A revision conflict ends in a clean process exit, not a crash to debug.Reviewed with a seven-dimension adversarial pass against the ticket text; no blocker or high findings. The low findings that were worth fixing landed in the
fixescommit. The merge commit resolves a generated-config conflict withmainby regenerating the file; against this branch it differs only in whitespace.Known limits, handed forward
deploy.mdxdocuments single-instance operation.prev + 1, so afterDELETE FROM shard_manager_staterevisions restart at1and a stale cached token can eventually compare equal. etcd's cluster revision cannot do this. Ticket 3's soundness argument depends on the fence, so on SQL this is a precondition to state, not an implementation detail.--auto-compaction-retentionthe backend eventually hits its quota and goes read-only, which with fail-stop is a crash loop. Needs a deployment note when etcd mode becomes real.STATE_KEYis a constant, so two deployments cannot share one etcd cluster.INSERThits theNOT NULLrevision column and panics. Intended fail-fast for a breaking release; belongs in the release notes.number_of_shardsconfig/state mismatch is unvalidated (pre-existing). Ticket 3 adds a startup guard.