feat(composite): count, report, durably record and repair failed secondary syncs (#1334) - #1428
Merged
Merged
Conversation
A composite write succeeds once the primary has committed it, in every HFS_COMPOSITE_SYNC_MODE. When a secondary then refused the change after SyncManager's retries, nothing recorded it: - synchronous / hybrid: `sync_to_secondaries` dropped the `Vec<SyncStatus>` that carried the failure, so its "Failed to sync ... to secondaries" warning only ever fired for a closed queue. The final failure was never logged at all, only the per-attempt "Sync attempt failed, retrying". - asynchronous: the worker logged "Async sync failed" with a backend id and an error, and no resource type, id or tenant. - batch (`create_many`, transaction bundles): statuses discarded likewise. The resource stayed missing from, or stale in, every search the secondary serves, with nothing to alert on and nothing naming it afterwards. Every final outcome now goes through a `SyncFailureRecorder` owned by the SyncManager (the one place the synchronous paths, the asynchronous worker and the batch path all pass through). Per decision on #1334 the write still succeeds; a final failure is 1. counted, through a `SecondarySyncObserver` the server wires to metrics (labels: backend, operation only), 2. emitted as one structured `error!` event with tenant, resource_type, id, version, backend_id, operation, attempts and error, never content, 3. recorded in a `SecondarySyncFailureLedger`: one row per (tenant, type, id, backend) holding operation, first/last_failed_at, last_error and attempts. Implemented on the SQLite (schema v34), PostgreSQL (schema v41) and MongoDB primaries; S3 has no ledger and degrades to metric + event. A later successful sync of the same resource clears its record (free while nothing is outstanding), and `CompositeStorage::repair_secondary_sync_failures` drains records in bounded batches by pushing the primary's current state (a delete when the primary no longer has it), re-reading the primary afterwards so it cannot leave an older version behind a concurrent write. `BackendError::Unavailable`'s Display omits its message, and that is what an exhausted Elasticsearch write reports since #1382, so the recorder keeps the detail explicitly. Also: SyncManager treated a secondary's NotFound on a Delete event as a failure and spent every retry on it. Not having the resource is the state a delete asks for; it is now success. Refs #1334
…odically Wires #1334's recorder into the server's four Elasticsearch composites: - `helios_observability::composite_metrics`: `composite_secondary_sync_failures_total{backend,operation}` and the `composite_secondary_sync_needs_reindex` gauge. Named like the crate's other metrics (no `hfs_` prefix; the exporter's global `service` label tells servers apart). Labels are the configured backend id and a fixed operation set only: `/metrics` is public, and tenant / type / id are unbounded. - SQLite, PostgreSQL and MongoDB primaries are their composite's ledger. S3 has none and says so at startup: metric + event only. - A periodic task, spawned like the search-parameter refresh tasks, drains the ledger through `repair_secondary_sync_failures` (HFS_COMPOSITE_SYNC_REPAIR_INTERVAL, default 60 s, 0 = off; HFS_COMPOSITE_SYNC_REPAIR_BATCH, default 100). It runs once at startup so records left by an earlier run are picked up. Adds the backend-agnostic ledger contract suite and runs it on SQLite, PostgreSQL and MongoDB, and documents the operator-facing behaviour in the run-hfs-server skill. Fixes #1334
…epair pass log Found running hfs against a stopped Elasticsearch: a stored body need not carry meta.versionId, so the structured event said version="" for every failed create. A create is version 1 unless the content says otherwise. The periodic task's log line said "Repaired" even for a pass that repaired nothing; it now names the pass and lets the counts speak. Refs #1334
smunini
added a commit
that referenced
this pull request
Sep 22, 2026
#1428 landed its own `migrate_v40_to_v41` (`secondary_sync_failures`), so this branch's bare-id reference index is renumbered to v41 -> v42: `SCHEMA_VERSION = 42`, `migrate_v41_to_v42`, and the fixture now starts from a v41 database so the CREATE INDEX fault injections target this migration rather than #1428's queue index.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the decision recorded on #1334: a composite write keeps succeeding in every
HFS_COMPOSITE_SYNC_MODE(the primary is the system of record and has already committed), and a secondary sync that fails for good is nowcomposite_secondary_sync_failures_total{backend,operation}plus acomposite_secondary_sync_needs_reindexgauge,ERRORwithtenant,resource_type,id,version,backend_id,operation,attempts,recorded,error; never resource content,It also fixes
SyncManagerburning every retry on a secondary'sNotFoundfor a Delete event.Fixes #1334
Observed pre-fix behaviour (reproduced first)
A failing-secondary stub (a SQLite index behind an on/off switch), using only APIs that exist on the base branch, run against the base's
composite/{storage,sync}.rs. Output (the stub'smax_retriesis 2, so 3 = every attempt spent):So, in every mode: the create is unsearchable, the update is stale, the deleted resource is still served by search, all three writes returned
Ok, and a Delete the secondary answersNotFoundto is retried to exhaustion. The issue's description holds, with one correction: it says "the only trace is awarn!log line". In the synchronous modes there was not even that for the final failure — see the table.Where a write's secondary sync goes (pre-fix)
All of
create,update,delete,delete_versioned,create_or_update,conditional_create/update/delete/patch,update_with_match,delete_with_matchend inCompositeStorage::sync_to_secondaries(event)→SyncManager::sync.synchronousJoinSet)sync_event_to_backend: 1 + 3 attempts, 100 ms ×2 back-off (on top of the ES backend's own 3 since #1397)SyncStatus { success: false, error }+BackendSyncStatus::total_errors; theVec<SyncStatus>was dropped bysync_to_secondaries, whosewarn!("Failed to sync … to secondaries")only fires for a closed queue. Only the per-attemptwarn!("Sync attempt failed, retrying")(no resource identity) was logged; the final failure neverhybridhybridtoHybrid { sync_for_search: true }, and every Create/Update/Delete counts as search-related, so single writes behave exactly assynchronousasynchronous(default)mpsc(1000); one worker drains it sequentiallyerror!("Async sync failed", backend, error)— no tenant, type, id or version — +total_errorsBatch paths go through
SyncManager::sync_createsinstead:create_many(conformance seeding, bulk loads), transaction/batch Bundles (sync_bundle_results) and bulk-submit'ssync_ingested. In the synchronous modes each secondary gets onecreate_many, then every rejected item is retried singly (1 + 3); in asynchronous mode one event per resource is queued.create_manyand the Bundle path discarded the statuses; bulk-submit turns rejected ids intoprocessing-errorentry results (#1007) and still does.Root cause
The outcome of a secondary sync was produced (
SyncStatus) but had no consumer: the synchronous path threw it away, the asynchronous worker had nowhere to put it but an anonymous log line, and nothing persisted which resource was affected.Fix
composite/sync_failures.rs(new):SyncFailureRecorder, owned by theSyncManagerbecause that is the one place every outcome passes through — the synchronous paths, the asynchronous worker (which finishes long after the write returned) and the batch path. On a final failure it notifies aSecondarySyncObserver(metrics), emits the structured event and upserts a record in aSecondarySyncFailureLedger. On a success it clears the resource's record — free on the healthy path: a process-local set of outstanding keys (hydrated from the ledger on first use, capped at 50 000, beyond which it degrades to always issuing the indexed delete) means the ledger is only touched while something is owed.sync_to_secondariesnow reads the statuses (adebug!tying the failure to the request's span; the recorder has already counted/logged/recorded it, so it is not logged twice)._bulkrequests, leaves all 11,704 Provenance resources unsearchable (1 % cut), spends 1,551 s on its first attempt alone before retrying all 24 types, and writes a SQLite index nothing reads #1125's ledger (DeferredReindexLedger) is not a general persistence abstraction: it is a singlerebuild_finishedcallback whose storage is a marker column on the bulk-submit manifest row (clear_manifest_index_pending). There is nothing per-resource to extend. What I reused is its pattern: a small trait in persistence, implemented by the primary backends, handed to the component asArc<dyn …>frommain.rs. Implemented for SQLite (tablesecondary_sync_failures, schema v33→v34), PostgreSQL (same table, v40→v41) and MongoDB (collection keyed by a compound_id, so no index and no schema-version step). S3 has no ledger and degrades to metric + event (recorded=falseon the event, and aninfo!at startup says so).CompositeStorage::repair_secondary_sync_failures(limit)— pushes the primary's current state of each recorded resource (create_or_update;deleteif the primary no longer has it,NotFound= done), one attempt per record per pass, least-recently-failed first (a record that fails again is re-stamped and goes to the back, so it cannot starve a bounded batch). Safe alongside writes: after pushing it re-reads the primary and, if the version moved, pushes again (≤ 3 rounds) so it does not leave an older version behind a concurrent write's own sync; a resource that keeps changing through all three rounds keeps its record for the next pass. Records naming a backend the composite no longer has are dropped with awarn!.crates/hfs/src/main.rs): the four*-escomposites get the metrics observer; sqlite/pg/mongo also get the ledger. A periodic task — sametokio::spawn+sleepshape as the existing search-parameter refresh tasks — calls the repair (HFS_COMPOSITE_SYNC_REPAIR_INTERVAL, default 60 s,0= off;HFS_COMPOSITE_SYNC_REPAIR_BATCH, default 100), first pass at startup so records from an earlier run are picked up.$reindexis not hooked directly (search/reindex.rsis outside this PR's ownership): it rebuilds the index, and the next repair pass then finds the records in sync and clears them.crates/observability/src/composite_metrics.rs): named like the crate's other metrics — nohfs_prefix (nothing in the repo has one; the exporter's globalservice="hfs"label does that job), socomposite_secondary_sync_failures_totalrather than the issue's suggestedhfs_composite_…. Labels:backend(configured id) andoperationonly.Unavailabledetail:BackendError::Unavailable'sDisplayomits itsmessage, and since fix(persistence): retry and stop discarding Elasticsearch write failures (#1382) #1397 that is exactly what an exhausted ES write returns — every record would have read "backend unavailable: elasticsearch". The recorder keeps the detail (truncated to 1024 chars).NotFound/Goneon a secondary is success insync_event_to_backend.Behaviour changes clients / operators can observe
ERROR "Secondary sync failed; …"with structured fields (the asynchronous worker's anonymous"Async sync failed"is replaced by it; synchronous modes gain it); a new table/collectionsecondary_sync_failuresin the primary (SQLite schema 34, PostgreSQL schema 41); a background repair task with two new env vars; deletes of resources the index never had no longer spend 3 retries.Verification
Ran and passed (branch head, base unchanged since branching;
CARGO_BUILD_JOBS=4):cargo test -p helios-persistence --features sqlite,postgres,mongodb,elasticsearch,s3 --no-fail-fast --lib --test composite_secondary_sync_failures --test composite_read_your_writes --test composite_polyglot_tests --test composite_conditional_capabilities --test composite_routing_tests --test composite_conformance_sync --test composite_index_during_ingest --test sqlite_tests→ lib 1911, new suite 9, 5 / 14 / 3 / 25 / 4 / 2,sqlite_tests98; 0 failed.cargo test -p helios-persistence --features sqlite,postgres,mongodb --no-fail-fast --test postgres_tests --test mongodb_tests --test composite_secondary_sync_failures→ 274 / 212 / 9, 0 failed (includes the new ledger contract on real PostgreSQL and MongoDB containers).cargo test -p helios-observability --lib composite_metrics→ 2 passed.cargo test -p helios-rest --no-fail-fast→ 1720 passed, 0 failed. (A first attempt died withNo space left on device; the disk had been filled by other work on the machine. Re-run clean.)cargo +1.98.1 clippywith the CI allow-list, all rc=0:-p helios-persistence --features sqlite,postgres,mongodb,elasticsearch,s3 --all-targets;-p helios-observability --all-targets;-p helios-hfs --features sqlite,postgres,mongodb,elasticsearch,s3 --all-targets; and the CI guard sets-p helios-hfs --no-default-features --featuresR4,sqlite,postgres/R4,postgres/R4,mongodb/R4,s3/R4,s3,elasticsearch.cargo fmt -p helios-persistence -p helios-observability -p helios-hfs -- --check→ clean.cargo +1.98.1 test --workspace --all-features --no-run --exclude pysof→ did not complete: twice killed byNo space left on device(the link step'starget/reached 432 GB on a disk shared with other builds; the only errors in the log are disk errors). In its place,cargo +1.98.1 check --workspace --all-features --all-targets --exclude pysof(same feature set and targets, type-checked but not linked): rc=0. Nocfg(all(feature ...))-gated code was added by this PR.hfssqlite-es,HFS_COMPOSITE_SYNC_MODE=synchronous, repair interval 5 s, real Elasticsearch 8.15 container): control create + search OK →docker stopES →POST /Patient= 201 →/metricsshowedcomposite_secondary_sync_failures_total{service="hfs",backend="es",operation="create"} 1andcomposite_secondary_sync_needs_reindex 1; theERRORevent carriedtenant=default resource_type=Patient id=… backend_id=es operation="create" attempts=4 recorded=true error=backend unavailable: elasticsearch: Failed to check index existence …; repair passes loggedstill_failing=1while ES was down →docker startES → next passrepaired=1 remaining=0, gauge back to0, counter still1, andGET /Patient?family=Lost1334found the resource.What the new tests cover (
tests/composite_secondary_sync_failures.rs, failing-secondary stub ×Synchronous/Asynchronous/Hybrid{true}/Hybrid{false}, asynchronous outcomes awaited through the sync queue barrier, no sleeps or timing assertions): positive control (healthy secondary indexes, nothing counted); create/update/delete against a down secondary all succeed; the observer is called exactly once per final failure with the right(backend, operation); one record per resource, a repeat failure folds (attemptsadds up,first_failed_atkept,operationreplaced); gauge tracks; a later successful write clears the record; the repair deletes what the primary deleted and creates what the secondary never received; a still-down secondary leaves the record and does not count a write failure; bounded batch + rotation; records survive re-opening the primary's database file; a record from an earlier run is cleared by the first successful write in the next; Delete +NotFoundis one call and success;create_manyrecords per resource; no ledger (S3 shape) still counts.Not run:
elasticsearch_tests,s3_tests/minio_s3_tests(no code they exercise changed beyondSyncManager, which the composite suites and--libcover), the Playwright UI ring, and nothing on Windows.Found, not fixed
DELETEentries are never synced to secondaries (code reading, not reproduced):sync_bundle_resultsonly looks at entries that carry a resource body, and a delete has none, so the deleted resource stays in the search index. It also sends Bundle updates throughsync_creates→create_many; harmless on Elasticsearch (index = upsert), wrong for a secondary whosecreaterejects an existing id.ingest_index_sink.rs) bypassesSyncManager, so its rejections are reported asprocessing-errorentry results but are not in the new ledger or counter.sender.send().awaitthen blocks client writes. Pre-existing; this PR adds one primary upsert per failed event to the worker and nothing to the client path.Createevent answeredAlreadyExistsby a non-upsert secondary (a lost response on the first attempt) is still retried to exhaustion and then recorded; the repair fixes it (create_or_update), but it should not fail in the first place.HealthMonitoris still not instantiated by the server, andCompositeStorage::update_healthis still never told about secondary outcomes.Stacking
Stacked on #1422 (
feat/1406-shared-conditional-patch, which contains #1421 and #1419) because those PRs edit the same composite write paths; base is that branch, notmain. Retarget tomainonce the chain merges. This PR does not touchconditional_patch/delete_versioned/ conditional code, the ES backend, or REST routing.