feat(aw-sync): v2 segment writer + manifest behind sync-v2 feature flag - #719
Conversation
Implements the first slice of the aw-sync v2 format spec
(knowledge/technical-designs/aw-sync-v2-segment-format.md).
New module: aw-sync/src/v2/
- manifest.rs: Manifest struct with atomic write+rename, version guard
(refuses v > MAX_V=1), load_or_default, per-bucket BucketEntry/SegmentEntry
with sealed/unsealed distinction
- segment.rs: SegmentWriter -- immutable JSONL+zstd segments under
devices/{device_id}/, open-tail reuse below SEAL_SIZE_BYTES (1 MiB),
new-generation on seal, SHA-256 of compressed bytes in manifest
Format details:
- Directory layout: devices/{device_id}/{slug}.{gen:08d}.jsonl.zst
- Slug = first 16 hex chars of sha256(bucket_id) -- never parsed for identity
- Segment line 1: JSON header (v, device_id, bucket_id, generation, replaces)
- Segment lines 2+: events in aw-core REST API format, nanosecond timestamps
- Sealed segments: permanent, SHA-256 authoritative
- Unsealed segments: open tail, SHA-256 updates each pass (normal)
- All writes: tmp file -> fsync -> atomic rename
Tests (9 passing):
- test_segment_write_produces_valid_zstd
- test_segment_immutability_after_seal
- test_manifest_reflects_two_segments
- test_no_events_is_noop
- test_bucket_slug_{length,stable,distinct}
- test_manifest_round_trip
- test_manifest_version_guard
Existing sync tests (62+24+3) pass byte-identical without the flag.
Feature flag: --features sync-v2 (off by default, no call sites in existing
push/pull/sync flows). No existing code paths modified.
Closes part of ActivityWatch#691.
Ref: ActivityWatch/activitywatch#1445
Git-Session-Id: b7f9
|
|
@TimeToBuildBob Structure matches the spec: P1 — the open-tail rewrite loses data. Seal by age too. Directory fsync. After Nit: |
…y-age Addresses Erik's review and Greptile P1/P2 findings on the v2 segment writer: - write_events now merges with the existing open-tail segment instead of overwriting it with only the events passed in a given call. A daemon pass that supplies "events since last pass" no longer drops the earlier events of that generation (dedupe by id, union, sorted). - write_segment_file sorts events chronologically before writing instead of trusting caller order — the datastore's natural retrieval order is newest-first, so passing that through unsorted violated the segment format's ordering contract. - Add SEAL_MAX_AGE (1 day, from the generation's first start_ts) so a low-volume bucket's tail eventually seals even if it never hits the size threshold. - fsync the device directory after each segment/manifest rename so the rename itself survives a crash, not just the file contents. - Add a sync-v2 feature CI job so the module and its tests actually run in CI instead of being silently excluded from every build. - Fix a pre-existing clippy::type_complexity on write_segment_file's return type (type alias) — caught while touching the function. Two new regression tests cover the open-tail merge and chronological ordering; existing 9 v2 tests still pass. Git-Session-Id: eeb0cd15-4db1-5ed4-b7d0-6b8ba42d37ba
|
@greptileai review |
|
@ErikBjare Fixed in 420d14c, all three points: P1 — open-tail rewrite loses data. Seal by age. Added Directory fsync. Both Also picked up Greptile's other P1 (segments were written in caller order, not sorted — datastore retrieval is newest-first, so this would have produced reverse-chronological segments) and its P2 (added a
|
Directory fsyncing via File::open is not supported on Windows — the OS does not permit opening a directory handle in a way that allows sync_all(). Guard the call with #[cfg(unix)] so the step is silently skipped on Windows (write-through semantics provide equivalent crash-safety guarantees for our rename pattern there). Fixes the windows-latest CI failure on the aw-sync v2 tests job. Git-Session-Id: 3d21
|
@ErikBjare Windows CI fixed in 2b29a1d. The All 11 v2 unit tests still pass on Linux. |
|
@greptileai review |
…ion time Addresses both remaining Greptile findings: - Windows renames now use MoveFileExW with MOVEFILE_WRITE_THROUGH instead of a no-op fsync, so a crash after a successful rename can't discard it (P1). - Seal-by-age is now measured from each generation's first_written_at (wall clock), not from the oldest event's timestamp, so importing historical events doesn't fragment into one segment per pass (P2). Git-Session-Id: 9245ad35-c1c8-54d5-9de3-05b09132e72a
|
@greptileai review |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #719 +/- ##
==========================================
+ Coverage 70.81% 79.73% +8.91%
==========================================
Files 51 75 +24
Lines 2916 8325 +5409
==========================================
+ Hits 2065 6638 +4573
- Misses 851 1687 +836 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Greptile caught the seal_max_age doc comment still describing the pre-420d14c behavior (age from start_ts); it's measured from first_written_at now. Git-Session-Id: 3dca23bb-8c3e-5452-b9d4-7c65194bcc05
🤖 AI code reviewAdds a new aw-sync v2 module behind a Not safe to merge — 1 P1 openConfidence 3/5
2 findings · ❌ 1 P1 ·
|
|
|
||
| /// `<sync_root>/devices/<device_id>` | ||
| pub fn device_dir(sync_root: &Path, device_id: &str) -> PathBuf { | ||
| sync_root.join("devices").join(device_id) |
There was a problem hiding this comment.
❌ P1 — The device_id argument is concatenated into the sync root path without sanitization. device_dir() returns sync_root.join("devices").join(device_id), and manifest_path and SegmentWriter::new use this directly. If device_id contains path separators or .., the resulting path escapes the intended device directory. For example, SegmentWriter::new("/tmp/sync", "../../evil", "bucket") causes fs::create_dir_all inside write_events to create or access /tmp/sync/devices/../../evil, i.e. /tmp/evil, and segment files are written under that attacker-controlled directory. A caller that accepts a remote or user-supplied device_id can thereby write arbitrary files outside the sync root, potentially overwriting sensitive files. Since the API is public in this PR, the vulnerability exists once any caller wires untrusted input into it.
There was a problem hiding this comment.
Fixed in c01765b — two issues addressed:
P1 — device_id path traversal: Added validate_device_id() called at the top of Manifest::load_or_default, which rejects any device_id containing /, \, or ... Covers the public API entry point; added a test exercising ../evil and similar forms.
P2 — stale manifest on missing segment file: Restructured the prev_sealed block so s.sealed = true only runs inside the successful fs::read arm. If the file is missing, the dangling manifest entry is removed with segments.retain(...) rather than being left advertised as sealed with a stale sha256. Added a test that externally deletes the segment file before the next write and confirms no gen=1 entry survives.
14 v2 unit tests pass.
| }; | ||
|
|
||
| // Seal previous generation if we're moving forward | ||
| let prev_sealed = should_start_new && self.generation > 0; |
There was a problem hiding this comment.
write_events decides to start a new generation because the previous segment file is missing (prev_path.exists() is false), it still sets prev_sealed = true (line 166) and then tries to mark the previous generation's manifest entry as sealed. If the manifest still contains an entry for that generation (e.g., the file was deleted externally after the manifest was saved), the code finds the entry, sets sealed = true, and attempts to recompute its sha256 by reading the now-missing file; the read fails silently, leaving a stale sha256. The manifest now advertises a sealed segment whose file is absent, so an importer that trusts sealed will attempt to open a nonexistent file and fail. Even when no manifest entry exists, the act of starting a new generation does not clean up the dangling reference, leaving the manifest inconsistent.
There was a problem hiding this comment.
Fixed in c01765b — see the P1 thread reply for details. The prev_sealed block now removes the dangling manifest entry when the file is missing rather than marking it sealed with a stale sha256.
|
CI-green and mergeable (Greptile 5/5) — waiting only on a maintainer click. This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted. |
…nifest on missing segment P1: device_id containing '/', '\', or '..' now rejected in Manifest::load_or_default (the single entry point for all manifest ops). Adds validate_device_id() and a test for ../evil and peer path-unsafe forms. P2: when a segment file was externally deleted and should_start_new fires, the prev_sealed branch was marking the manifest entry sealed without a valid sha256. Now: if the file can be read, sha256+sealed update as before; if the file is missing, the dangling entry is removed from the manifest instead of advertising a sealed segment that no longer exists. Both found by the local AI reviewer (score 3/5 → re-review pending). 14 v2 unit tests pass. Git-Session-Id: 33e6a83a-9a16-5faf-ac0a-25021111b2c6
|
@greptileai review |
… dropping history When fs::read on the previous segment fails with anything other than NotFound, the code was falling into the else branch and silently removing the manifest entry — treating a transient I/O error (permissions, hardware failure, Windows sharing violation) as an intentional external deletion and losing the history recorded in that generation. Fix: match on the error kind and only drop the entry on ErrorKind::NotFound. Any other error propagates to the caller so the manifest is not corrupted. Added test_io_error_during_seal_propagates (Unix): replaces the segment file with a self-referential symlink so exists() returns false (triggering prev_sealed) while fs::read fails with ELOOP (not ENOENT) — confirming the non-NotFound branch returns an error instead of silently dropping the entry. Git-Session-Id: c5ff9310-3d4d-5174-ad95-eeabf6f6fb9b
|
@greptileai review |
|
Greptile's P1 "Read Errors Drop History" is fixed in 9dfdac2. The bug: the Fix: Status: Greptile 5/5 ✓, CI green, all review threads resolved. Waiting for a maintainer merge. |
Git-Session-Id: f4686f21-0e57-50ac-b566-ec6edb2e915d
|
@ErikBjare all four points from your review are now in; head is 7e7c366.
Changes since your review that weren't in the "Fixed in 420d14c" reply:
CI is re-running on the docs commit; the last full run (9dfdac2) was green on all platforms with Greptile 5/5. Waiting on your review/merge. |
Summary
First slice of the aw-sync v2 format redesign, spec'd in activitywatch#1445 (restart comment 2026-09-18) and #691.
The current sync folder uses mutable sqlite databases (
test.db) — cross-server incompatible, write-unsafe for file syncers, and unversioned. This PR adds the v2 writer behind a feature flag; no existing code paths are touched.What this adds
New module
aw-sync/src/v2/(gated by--features sync-v2, off by default):manifest.rs—Manifeststruct:load_or_default— reads or createsdevices/{device_id}/manifest.jsonv > 1with a clear error (replaces the// TODO: Check for compatible remote db versioncomment in the existing code)BucketEntrywith slug (filename mapping), type/client/hostname/created, and asegmentslistsegment.rs—SegmentWriter:devices/{device_id}/{slug}.{gen:08d}.jsonl.zstsha256(bucket_id)— never parsed for identity (handles bucket ids with spaces,:, unicode)replaces: null).{name}.tmp→ fsync → atomic renameWhat this does NOT do
test.dbpath--v2) is a follow-up PRTests (9)
Existing tests (62 unit + 24 sync integration + 3 roundtrip) pass byte-identical without the feature flag.
Sequence context
This is step 1 of 5 per Erik's stated order on activitywatch#1445:
(device_id, name)bucket identity — Allow ActivityWatch to Ignoring / Filtering #302pull_allsqlite-copy semanticstest.dbretirementTest plan
cargo test -p aw-sync --features sync-v2 v2— all 9 passcargo test -p aw-sync(no flag) — 62+24+3 pass, no regressionscargo build -p aw-sync(no flag) — clean, no v2 symbols in default binaryCloses part of #691.
Ref: ActivityWatch/activitywatch#1445