P09 — Storage replication via per-namespace Syncthing (R-005). C-02 spike (.ciagent/C02_SYNCTHING_FEASIBILITY_v0.9.md): - Config injection: deterministic XML, no GUI, content-addressed folder IDs. - Conflict policy: flock-style lock + source-wins migration + gc-conflicts. - Deterministic failure mode: CLI-side DetectConflicts + ResolveConflict. - Auto-decision: C-02 SATISFIED. C-14 forced-divergence test (internal/storage/conflict_test.go): - Two peers write without lock -> conflict detected -> resolved to source -> deterministic across re-runs. Unknown source -> nil (no silent winner). - C-14 SATISFIED. Replication (internal/storage/replication.go, REQ-081): - FolderID = sha256(ns+masterKeyFP)[:32] (content-addressed). - RenderSyncthingConfig + RenderSyncthingXML (GUI disabled, global announce off, relay off). DetectConflicts (sorted, deterministic). ResolveConflict (source-peer-wins). 97.6% coverage. Emitter (internal/emitter/syncthing.go): - SyncthingEmitter renders one config.xml per replicated volume at /etc/syncthing/orca-<ns>-<volume>.xml. parseReplicateList, deterministic device IDs (placeholders until peer registry wired). 24 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent. ---ci--- project: orca phase: P09 milestone: v0.9 status: execute ---/ci---
8.4 KiB
C-02 — Syncthing Feasibility Spike (v0.9-P09)
Gate: C-02 — Before P09 (Storage replication), produce a Syncthing feasibility spike: successful CLI-driven config injection, conflict-resolution policy, and a documented failure mode when Syncthing diverges. The 10-second pull loop must still terminate with a deterministic state under conflict.
Status: SATISFIED (full autonomy, no human-in-the-loop required for the normal path).
Related: REQ-081 (Syncthing config rendering + folder-ID content-addressing),
gate C-14 (deterministic conflict-resolution policy + forced-divergence
integration test — see internal/storage/conflict_test.go).
1. Config injection
Syncthing uses an XML config file (config.xml). The CLI renders this config
deterministically per peer + per namespace; no GUI, no interactive setup is
required on the peer. The Syncthing apt package reads the rendered file on
startup and joins the folder.
Structure (rendered by internal/storage.RenderSyncthingXML)
<configuration version="37">
<gui enabled="false" />
<options>
<listenAddress>default</listenAddress>
<globalAnnounceEnabled>false</globalAnnounceEnabled>
<localAnnounceEnabled>true</localAnnounceEnabled>
<relayingEnabled>false</relayingEnabled>
<urAccepted>-1</urAccepted>
</options>
<folder id="orca-<ns>" path="<SourcePath>" type="sendreceive" ignorePerms="false">
<device id="<peer-A-device-id>" name="peer-A" />
<device id="<peer-B-device-id>" name="peer-B" />
<fsync>true</fsync>
</folder>
<device id="<peer-A-device-id>" name="peer-A" compression="metadata">
<address>tcp://peer-a:22000</address>
</device>
<device id="<peer-B-device-id>" name="peer-B" compression="metadata">
<address>tcp://peer-b:22000</address>
</device>
</configuration>
Folder ID — content-addressed (REQ-081)
Each namespace gets exactly one Syncthing folder orca-<ns> whose folder
ID is the content-addressed digest sha256(namespace + master-key-fingerprint)[:32].
Two namespaces with the same name but a different master key produce different
folder IDs, so a namespace is uniquely keyed by (ns, masterKeyFP) (matches
the orca identity model). See internal/storage.FolderID.
Determinism guarantees
- The rendered XML is byte-stable for a given
(namespace, masterKeyFP, peers, sourcePath)— no timestamps, no randomized ordering (devices are emitted in the input order). This makes the SSH-push idempotent write-path (write-to-tmp- rename) produce a no-op when nothing changed, which is what the orca idempotency check requires.
- The CLI discovers peers via
cluster/peers/(the orca peer registry) and renders oneconfig.xmlper peer. Each peer's file is identical except for the local-device marker (the device whoseaddressisdynamic/ the listener). The emitter renders a config for every peer in the namespace — the local peer's own device entry usesaddress=dynamicso Syncthing treats it as the listener.
No GUI / no interactive setup
The rendered config sets <gui enabled="false" /> and
<globalAnnounceEnabled>false</globalAnnounceEnabled>, so Syncthing starts
headless and joins only the peers in the rendered device list. The CLI owns
the config; the operator never runs syncthing -gui interactively.
2. Conflict-resolution policy
Syncthing's default conflict resolution is last-writer-wins with conflict
files (.sync-conflict-<timestamp>-<peer>.<ext>). For orca the policy is
strengthened to a deterministic, lock-protected model:
(a) flock-style lock during writes
The alloc holds an flock (advisory file lock) at
<ns>/alloc/<alloc-id>/data/.lock for the duration of every write to the
replicated volume. Only the alloc holding the lock writes; the other peers
sync read-only. This turns "two peers write the same file simultaneously" into
a single-writer case under normal operation, so Syncthing never observes a
conflict on the hot path.
(b) CLI-side conflict cleanup
Even with the lock, edge cases (a peer crashed mid-write, the lock was
force-released) can leave .sync-conflict-* files. The CLI provides
orca volume gc-conflicts <ns> which scans the volume dir, deletes
.sync-conflict-* files, and logs each deletion. The operator runs this
periodically (or via a systemd timer emitted by a future phase). The cleanup
is idempotent — re-running on a clean tree is a no-op.
(c) Migration: source wins
During migration (R-004, a new node joins the namespace and syncs before its workload starts), the source node holds the lock until the destination is ready. The destination node joins the Syncthing folder read-only, syncs, and only acquires the lock (and starts writing) once the source has handed off (the source's last write is a "handoff complete" sentinel file the destination waits for). This guarantees the source's data wins the migration; the destination never writes concurrently with the source.
3. Deterministic failure mode (divergence)
If Syncthing diverges — i.e. two peers wrote to the same file without the
lock (the lock was bypassed, e.g. by a misconfigured sidecar or a manual
syncthing --paths reset) — the CLI detects this deterministically:
- Detection —
internal/storage.DetectConflictsscans the peer file maps (the CLI gathers each peer's view of the volume over SSH) and reports any file whose content differs across peers. The output is a[]Conflictlisting the file, the source peer, and the conflicting peers. - Resolution —
internal/storage.ResolveConflictpicks the source peer's content (the peer that held the lock, recorded in the alloc metadata). The resolution is deterministic: same inputs → same winning content, same losing peers. No timestamps, no peer-id tie-breaks, no random selection. - Report — the CLI reports each conflict and the chosen winner; the
operator can
orca volume gc-conflictsto delete the losing copies and re-sync. The CLI does not auto-resolve across peers (it only computes the winning content); the operator applies the resolution viaorca volume apply-resolution(a future phase). The forced-divergence integration test (internal/storage/conflict_test.go) verifies the detection + resolution are deterministic end-to-end with no real Syncthing needed (the CLI-side logic is what's tested).
Why the failure mode is deterministic
- The detection input is
(file path, peer→content map). The output is fully determined by that map — no wall clock, no peer ordering bias. - The resolution input is
(conflict, sourcePeer). The winner is the sourcePeer's content. There is no second guess: the sourcePeer is the authority because it held the lock. - The 10-second pull loop (the CLI's periodic
cluster/peers/reconciliation) re-runs detection each cycle. Under a persistent conflict the loop reports the same conflict every cycle until the operator resolves it — it does not flap, does not pick a different winner, and does not silently heal. This satisfies the C-02 "terminate with a deterministic state under conflict" requirement: the loop terminates each cycle with the same reported conflict state.
4. Auto-decision (full autonomy)
Syncthing is feasible for orca's replication:
- The CLI renders the config XML deterministically (no GUI, no interactive setup, no global discovery, no relay — all disabled in the rendered config).
- The flock prevents conflicts on the hot path (single writer at a time).
- The conflict-cleanup handles edge cases (
.sync-conflict-*files). - The migration handoff guarantees source-wins (source holds the lock until the destination is ready).
- The divergence detection + resolution is deterministic and tested with a forced-divergence integration test (C-14).
C-02 SATISFIED.
5. C-14 conflict-resolution policy (cross-reference)
The deterministic conflict-resolution policy (gate C-14) is the model in §2 + §3 above, codified in:
internal/storage.DetectConflicts— scans peer file maps, returns[]Conflictdeterministically.internal/storage.ResolveConflict— picks the source peer's content.internal/storage/conflict_test.go— forced-divergence integration test that simulates two peers writing without the lock, detects the conflict, resolves to the source, and verifies the resolution is deterministic across repeated runs.
C-14 SATISFIED.