Skip to content

aw-sync writes to peer databases on every pull: WAL flip + schema migration on files it does not own #693

Description

@ErikBjare

The core invariant aw-sync's whole conflict-free story rests on — stated in aw-sync/README.md as "each device only writes to files in the sync folder they own, and other devices may not modify them" — is violated on every pull.

What happens

create_datastore opens a peer database read-write:

pub fn create_datastore(path: &Path) -> Result<Datastore, String> {
    ...
    Ok(Datastore::new(pathstr.to_string(), false))
}

and Datastore::new (aw-datastore/src/worker.rs) unconditionally:

let journal_mode: String = conn
    .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
    .expect("Failed to query journal_mode");
...
conn.pragma_update(None, "synchronous", "FULL")
...
let mut ds = DatastoreInstance::new(&conn, true).unwrap();

So reading a peer's file:

  1. Flips its journal_mode to WAL, creating -wal and -shm sidecars inside a directory owned by another device.
  2. Runs schema migrations on itDatastoreInstance::new(&conn, true) upgrades user_version toward the local binary's version.
  3. Writes synchronous = FULL.

All three are writes to a file this device does not own, performed by a read-only operation, in a directory an external file syncer is actively replicating.

Why it matters concretely

  • Version skew makes it a real migration, not a no-op. Peer databases in a live sync folder sit at different user_versions, below current master's. An older peer's file gets silently upgraded by whichever device pulls it first — and that device may be running a newer aw-server-rust than the peer that owns the file. The owner then reopens a database migrated by someone else's binary.
  • It manufactures sync conflicts. Two devices pulling the same peer, or a pull racing the owner's push, are concurrent writers to one file. My folder already contains the fingerprints: erb-main3/5a5df0f8-…/test.sync-conflict-20241125-052022-GRUSU5T.db plus a second from the same day.
  • The -wal/-shm sidecars are themselves replicated, and a syncer that delivers a main file and its WAL at different moments produces exactly the torn snapshot the sidecars were meant to prevent.
  • It compounds aw-sync: leftovers after #685/#686 — orphaned 2-level staging db, stale -synced-from- buckets, walker enters dot-dirs #689's finding that pulls also create empty staging directories inside peers' folders. Between the two, a pull writes to a peer's directory in three different ways.

Fix

Open peer databases read-only and side-effect-free:

  • sqlite3_open_v2 with SQLITE_OPEN_READONLY, or a file:…?mode=ro URI
  • never run migrations on a file this device does not own — if user_version is unrecognised, refuse that peer and report it (there is already a // TODO: Check for compatible remote db version before opening in sync.rs), rather than upgrading it
  • do not set journal_mode or synchronous on peer files
  • if a read-only open is impractical for a WAL-mode peer, copy the file to a scratch location first and open the copy — never the original

Note today's Datastore::new also spawns a worker thread and min/max-scans every bucket on open, which is heavy for what a pull needs; a lightweight read-only open path would help #684's status command too.

Credit: found in a design review of aw-sync; the code path above is verified against origin/master, the conflict files are from a live sync folder.

Related: #689, #691 (sqlite as wire format), #682.

cc @TimeToBuildBob

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions