docs: define sync conflict semantics
This commit is contained in:
parent
02969ac7af
commit
24a0a3d153
5 changed files with 186 additions and 14 deletions
|
|
@ -445,6 +445,8 @@ validity, and representative peer grants when local metadata is available. Use
|
||||||
|
|
||||||
See `docs/automation-examples.md` for shell, Python JSON, and user-service
|
See `docs/automation-examples.md` for shell, Python JSON, and user-service
|
||||||
automation examples.
|
automation examples.
|
||||||
|
See `docs/conflict-semantics.md` for the current per-resource conflict behavior
|
||||||
|
used by CAS/file roots, KV, documents, DB sync, and signed metadata logs.
|
||||||
|
|
||||||
## Owner And Node Management
|
## Owner And Node Management
|
||||||
|
|
||||||
|
|
@ -666,6 +668,8 @@ stability rules for CLI commands, `--json` output, the local control JSONL
|
||||||
protocol, Iroh peer wire protocols, SQLite metadata, and signed operation logs.
|
protocol, Iroh peer wire protocols, SQLite metadata, and signed operation logs.
|
||||||
Command-family stability levels are tracked in
|
Command-family stability levels are tracked in
|
||||||
[`docs/command-stability.md`](docs/command-stability.md).
|
[`docs/command-stability.md`](docs/command-stability.md).
|
||||||
|
Per-resource conflict behavior is tracked in
|
||||||
|
[`docs/conflict-semantics.md`](docs/conflict-semantics.md).
|
||||||
|
|
||||||
GitHub Actions workflows live under `.github/workflows/`:
|
GitHub Actions workflows live under `.github/workflows/`:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1943,9 +1943,13 @@ async fn kv_sync_from_peer(
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
geth_kv::validate_kv_key(&entry.key)
|
geth_kv::validate_kv_key(&entry.key)
|
||||||
.map_err(|_| NodeError::InvalidKvKey(entry.key.clone()))?;
|
.map_err(|_| NodeError::InvalidKvKey(entry.key.clone()))?;
|
||||||
let should_import = store
|
let should_import = should_import_kv_sync_entry(
|
||||||
.get_kv_entry(&kv.kv_id, &entry.key)?
|
store
|
||||||
.is_none_or(|local| entry.updated_at_ms >= local.updated_at_ms);
|
.get_kv_entry(&kv.kv_id, &entry.key)?
|
||||||
|
.as_ref()
|
||||||
|
.map(|local| local.updated_at_ms),
|
||||||
|
entry.updated_at_ms,
|
||||||
|
);
|
||||||
if should_import {
|
if should_import {
|
||||||
store.set_kv_entry(&StoredKvEntry {
|
store.set_kv_entry(&StoredKvEntry {
|
||||||
kv_id: kv.kv_id.clone(),
|
kv_id: kv.kv_id.clone(),
|
||||||
|
|
@ -3229,12 +3233,12 @@ async fn document_sync_from_peer(
|
||||||
let mut updated = false;
|
let mut updated = false;
|
||||||
if let Some(state) = state {
|
if let Some(state) = state {
|
||||||
let local = ensure_local_document(&store, name)?;
|
let local = ensure_local_document(&store, name)?;
|
||||||
if state.updated_at.0 >= local.updated_at_ms {
|
if let Some(state_json) = document_sync_state_json(
|
||||||
let state_json = if state.updated_at.0 > local.updated_at_ms {
|
&local.state_json,
|
||||||
state.state_json
|
local.updated_at_ms,
|
||||||
} else {
|
&state.state_json,
|
||||||
geth_document::merge_automerge_states(&local.state_json, &state.state_json)?
|
state.updated_at.0,
|
||||||
};
|
)? {
|
||||||
store.insert_document_resource(&StoredDocumentResource {
|
store.insert_document_resource(&StoredDocumentResource {
|
||||||
document_id: local.document_id,
|
document_id: local.document_id,
|
||||||
resource_id: local.resource_id,
|
resource_id: local.resource_id,
|
||||||
|
|
@ -3267,6 +3271,32 @@ async fn document_sync_from_peer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_import_kv_sync_entry(
|
||||||
|
local_updated_at_ms: Option<i64>,
|
||||||
|
remote_updated_at_ms: i64,
|
||||||
|
) -> bool {
|
||||||
|
local_updated_at_ms
|
||||||
|
.is_none_or(|local_updated_at_ms| remote_updated_at_ms >= local_updated_at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_sync_state_json(
|
||||||
|
local_state_json: &str,
|
||||||
|
local_updated_at_ms: i64,
|
||||||
|
remote_state_json: &str,
|
||||||
|
remote_updated_at_ms: i64,
|
||||||
|
) -> Result<Option<String>, NodeError> {
|
||||||
|
if remote_updated_at_ms < local_updated_at_ms {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if remote_updated_at_ms > local_updated_at_ms {
|
||||||
|
return Ok(Some(remote_state_json.to_owned()));
|
||||||
|
}
|
||||||
|
Ok(Some(geth_document::merge_automerge_states(
|
||||||
|
local_state_json,
|
||||||
|
remote_state_json,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
async fn sync_status_from_peer(
|
async fn sync_status_from_peer(
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
peer_node: &str,
|
peer_node: &str,
|
||||||
|
|
@ -11392,6 +11422,40 @@ mod tests {
|
||||||
std::env::var_os("GETH_TEST_SKIP_IROH").is_some()
|
std::env::var_os("GETH_TEST_SKIP_IROH").is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_sync_import_rule_is_timestamp_last_writer_wins() {
|
||||||
|
assert!(should_import_kv_sync_entry(None, 10));
|
||||||
|
assert!(!should_import_kv_sync_entry(Some(11), 10));
|
||||||
|
assert!(should_import_kv_sync_entry(Some(10), 10));
|
||||||
|
assert!(should_import_kv_sync_entry(Some(9), 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_sync_rule_merges_equal_timestamps_only() {
|
||||||
|
let older =
|
||||||
|
document_sync_state_json(r#"{"title":"local"}"#, 20, r#"{"title":"remote"}"#, 19)
|
||||||
|
.expect("older remote");
|
||||||
|
assert_eq!(older, None);
|
||||||
|
|
||||||
|
let newer =
|
||||||
|
document_sync_state_json(r#"{"title":"local"}"#, 20, r#"{"title":"remote"}"#, 21)
|
||||||
|
.expect("newer remote");
|
||||||
|
assert_eq!(newer.as_deref(), Some(r#"{"title":"remote"}"#));
|
||||||
|
|
||||||
|
let local_state =
|
||||||
|
geth_document::create_automerge_state(r#"{"local":true}"#).expect("local envelope");
|
||||||
|
let remote_state =
|
||||||
|
geth_document::create_automerge_state(r#"{"remote":true}"#).expect("remote envelope");
|
||||||
|
let equal = document_sync_state_json(&local_state, 20, &remote_state, 20)
|
||||||
|
.expect("equal timestamp merge")
|
||||||
|
.expect("merged state");
|
||||||
|
let merged: serde_json::Value =
|
||||||
|
serde_json::from_str(&geth_document::document_view_json(&equal).expect("view json"))
|
||||||
|
.expect("merged json");
|
||||||
|
assert_eq!(merged["local"], true);
|
||||||
|
assert_eq!(merged["remote"], true);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum RemoteGuardKind {
|
enum RemoteGuardKind {
|
||||||
Capability,
|
Capability,
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,9 @@ Resource kinds:
|
||||||
|
|
||||||
## Module Overview
|
## Module Overview
|
||||||
|
|
||||||
|
The current per-resource sync and conflict behavior is a documented
|
||||||
|
automation-facing contract in [`conflict-semantics.md`](conflict-semantics.md).
|
||||||
|
|
||||||
`geth-cas` is implemented locally first using BLAKE3 hashes and filesystem blob
|
`geth-cas` is implemented locally first using BLAKE3 hashes and filesystem blob
|
||||||
storage. Local pin/unpin metadata is tracked in SQLite and surfaced in
|
storage. Local pin/unpin metadata is tracked in SQLite and surfaced in
|
||||||
`cas list`. `cas cleanup` removes unpinned local blobs while retaining pinned
|
`cas list`. `cas cleanup` removes unpinned local blobs while retaining pinned
|
||||||
|
|
|
||||||
101
docs/conflict-semantics.md
Normal file
101
docs/conflict-semantics.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Conflict Semantics
|
||||||
|
|
||||||
|
This document describes the current pre-deployment conflict behavior for
|
||||||
|
resource sync. It is an automation contract for scripts built on `geth`; it is
|
||||||
|
not a claim that every resource has final multi-writer reconciliation.
|
||||||
|
|
||||||
|
All remote sync paths first authenticate the peer-card endpoint binding and
|
||||||
|
check the resource capability for the requested stream. Discovery metadata,
|
||||||
|
peer-card import, or LAN discovery never grants conflict-resolution rights by
|
||||||
|
itself.
|
||||||
|
|
||||||
|
## CAS File Roots
|
||||||
|
|
||||||
|
CAS blobs are immutable and addressed by BLAKE3 hash, so blob fetch has no merge
|
||||||
|
conflict. File-root sync and apply are the stateful parts:
|
||||||
|
|
||||||
|
- `geth cas root sync <node> <name>` imports authorized remote tree metadata and
|
||||||
|
tree bytes into a peer-qualified remote root. It does not write files.
|
||||||
|
- Repeated syncs retain the previous imported remote tree as the base. When a
|
||||||
|
same-named local root and the new remote root both changed, geth records
|
||||||
|
durable `concurrent-edit`, `delete-edit`, or `rename` conflicts.
|
||||||
|
- `geth cas root apply <root> --to <path>` is conservative. Without a
|
||||||
|
registered local base it creates missing files/directories, does not delete
|
||||||
|
extra files, and does not overwrite differing local files.
|
||||||
|
- With a registered local root base, apply performs a three-way
|
||||||
|
base/local/remote check. It applies non-conflicting creates, updates, deletes,
|
||||||
|
and renames only where local state still matches the base.
|
||||||
|
- Ambiguous paths remain local conflict records until resolved through
|
||||||
|
`geth cas conflict resolve`.
|
||||||
|
|
||||||
|
The current tests cover CAS tree diffing, safe three-way apply, durable
|
||||||
|
file-conflict records, and automatic sync conflict recording.
|
||||||
|
|
||||||
|
## KV Stores
|
||||||
|
|
||||||
|
Named KV stores are durable local SQLite indexes mirrored through native
|
||||||
|
Iroh Documents when available.
|
||||||
|
|
||||||
|
- Manual `geth kv sync` and background live-sync require `kv.read` on
|
||||||
|
`resource:kv:<name>`.
|
||||||
|
- Import is timestamp-based last-writer-wins per key.
|
||||||
|
- A remote value replaces the local value when the remote `updated_at_ms` is
|
||||||
|
greater than or equal to the local timestamp.
|
||||||
|
- A remote value older than the local value is ignored.
|
||||||
|
- There is no tombstone or multi-value conflict record for KV yet.
|
||||||
|
|
||||||
|
The current tests cover authorized KV sync and the timestamp import rule.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
Document resources persist Automerge save bytes, while the CLI exposes a
|
||||||
|
validated JSON view.
|
||||||
|
|
||||||
|
- Manual `geth document sync` and background live-sync require `document.read`
|
||||||
|
on `resource:document:<name>`.
|
||||||
|
- A remote state older than the local document timestamp is ignored.
|
||||||
|
- A remote state newer than the local timestamp replaces the local Automerge
|
||||||
|
state and JSON view.
|
||||||
|
- Equal timestamps are merged with the current Automerge merge helper.
|
||||||
|
- Rich document conflict UI, semantic JSON merge policy, and per-field conflict
|
||||||
|
reporting are future work.
|
||||||
|
|
||||||
|
The current tests cover Automerge merge behavior, authorized document sync, and
|
||||||
|
the sync timestamp decision rule.
|
||||||
|
|
||||||
|
## DB Resources
|
||||||
|
|
||||||
|
DB resources are a staged cr-sqlite path rather than a general SQLite merge
|
||||||
|
engine.
|
||||||
|
|
||||||
|
- Manual `geth db sync` and background live-sync require `db.sync` on
|
||||||
|
`resource:db:<name>`.
|
||||||
|
- The requester compares remote schema metadata with the local DB schema before
|
||||||
|
applying a batch.
|
||||||
|
- Compatible batches are inserted into the local `crsql_changes` table or view,
|
||||||
|
then the per-peer cursor advances.
|
||||||
|
- Incompatible schema metadata causes the batch to be skipped and the cursor is
|
||||||
|
not advanced for that batch.
|
||||||
|
- Loading/configuring the cr-sqlite extension for real application databases is
|
||||||
|
outside this bootstrap.
|
||||||
|
|
||||||
|
The current tests cover deterministic `crsql_changes` extraction/application,
|
||||||
|
authorized DB sync, and schema-compatible batch application. Schema evolution
|
||||||
|
and rollback expectations are tracked in the upgrade-test roadmap item.
|
||||||
|
|
||||||
|
## Signed Logs And Metadata Sync
|
||||||
|
|
||||||
|
Keychain, auth, SSH certificate, and SSH revocation sync are append-only
|
||||||
|
metadata flows with provenance checks.
|
||||||
|
|
||||||
|
- New keychain/auth entries must have valid signatures from currently trusted
|
||||||
|
admin keys.
|
||||||
|
- SSH certificate and revocation entries must carry valid agent-key signed
|
||||||
|
provenance over canonical payloads.
|
||||||
|
- If an incoming record has an already-known id but different content, the
|
||||||
|
import rejects that record instead of overwriting local metadata.
|
||||||
|
- Accepted keychain/auth op/signature groups are staged and committed through
|
||||||
|
store batch transactions.
|
||||||
|
|
||||||
|
The current tests cover unsigned, invalidly signed, and conflicting signed-log
|
||||||
|
imports.
|
||||||
|
|
@ -221,12 +221,12 @@ Goal: make convergence and failure behavior predictable enough for automation.
|
||||||
- `[ ]` Tests cover partial stream failure.
|
- `[ ]` Tests cover partial stream failure.
|
||||||
- `[ ]` Tests cover duplicate records and stale cursors.
|
- `[ ]` Tests cover duplicate records and stale cursors.
|
||||||
|
|
||||||
- `[ ]` Define per-resource conflict semantics.
|
- `[x]` Define per-resource conflict semantics.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[ ]` CAS/file-root conflict semantics are documented and tested.
|
- `[x]` CAS/file-root conflict semantics are documented and tested.
|
||||||
- `[ ]` KV conflict semantics are documented and tested.
|
- `[x]` KV conflict semantics are documented and tested.
|
||||||
- `[ ]` Document merge semantics are documented and tested.
|
- `[x]` Document merge semantics are documented and tested.
|
||||||
- `[ ]` DB change application limits are documented and tested.
|
- `[x]` DB change application limits are documented and tested.
|
||||||
|
|
||||||
- `[ ]` Add live-sync retry and backoff policy.
|
- `[ ]` Add live-sync retry and backoff policy.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue