147 lines
6.2 KiB
Markdown
147 lines
6.2 KiB
Markdown
# M7.6 — Sync framework
|
||||
|
|
|
|||
|
|
| Field | Value |
|
|||
|
|
|---|---|
|
|||
|
|
| Phase | M7 — Source connectors |
|
|||
|
|
| Size | L — 3–5 days |
|
|||
|
|
| Status | ⬜ Not started |
|
|||
|
|
| Flags | — |
|
|||
|
|
| Spec | inlined below |
|
|||
|
|
| Blocks | M7.7, M7.10 |
|
|||
|
|
| Depends | M7.1, M3.6.3 |
|
|||
|
|
|
|||
|
|
## Goal
|
|||
|
|
|
|||
|
|
Build the shared sync engine that handles change detection, tombstoning, drift
|
|||
|
|
reporting, and resumable sync for **all** connectors — so individual connector
|
|||
|
|
implementations only fetch documents and everything else is handled once.
|
|||
|
|
|
|||
|
|
## Facts (inlined — no spec read needed)
|
|||
|
|
|
|||
|
|
Every connector faces the same sync problems:
|
|||
|
|
1. **What changed?** Compare content hashes from `list_documents()` against a
|
|||
|
|
manifest of last-known hashes. Only fetch and process changed documents.
|
|||
|
|
2. **What disappeared?** Documents in the manifest but not in `list_documents()`
|
|||
|
|
need tombstone records in the log. Append-only — never delete.
|
|||
|
|
3. **What if sync crashes midway?** Track progress per-document. Restart
|
|||
|
|
processes only unseen documents.
|
|||
|
|
4. **How much will this cost?** Drift report shows counts without mutating
|
|||
|
|
anything.
|
|||
|
|
|
|||
|
|
This is the generalization of M3.6.3's `mem ref sync/list/rm` logic, applied to
|
|||
|
|
any `SourceConnector` instead of just `DocCorpusSource`.
|
|||
|
|
|
|||
|
|
**Manifest storage.** Per-connector manifest in `log/connectors/<name>.manifest.jsonl`:
|
|||
|
|
```jsonl
|
|||
|
|
{"doc_id":"abc","source_uri":"file:///vault/k8s.md","content_hash":"sha256:...","chunk_count":12,"synced_at":"..."}
|
|||
|
|
{"doc_id":"def","source_uri":"paperless://doc/42","content_hash":"sha256:...","chunk_count":3,"synced_at":"..."}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Sync algorithm:**
|
|||
|
|
```
|
|||
|
|
current = connector.list_documents()
|
|||
|
|
previous = load_manifest(connector.name)
|
|||
|
|
|
|||
|
|
for doc in current:
|
|||
|
|
if doc.content_hash == previous[doc.doc_id].content_hash:
|
|||
|
|
skip (unchanged)
|
|||
|
|
else if doc.doc_id in previous:
|
|||
|
|
tombstone previous chunks, fetch + chunk + embed new (changed)
|
|||
|
|
else:
|
|||
|
|
fetch + chunk + embed (new)
|
|||
|
|
|
|||
|
|
for doc_id in previous not in current:
|
|||
|
|
tombstone all chunks (removed)
|
|||
|
|
|
|||
|
|
save_manifest(connector.name, current)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Chunking delegation.** The sync framework owns the chunking step. It routes
|
|||
|
|
fetched `DocumentContent` through the appropriate `ChunkPolicy`:
|
|||
|
|
- Markdown → heading-boundary chunking (reuse `DocCorpusSource` logic)
|
|||
|
|
- Plain text → paragraph-boundary chunking
|
|||
|
|
- Configurable per connector kind in `connectors.yaml`
|
|||
|
|
|
|||
|
|
**Level routing.** Session connectors → RecordSource → gated loop (L0/L1/L2).
|
|||
|
|
Document connectors → Reference records (Level R). The `source_type()` method
|
|||
|
|
on the connector determines the pipeline.
|
|||
|
|
|
|||
|
|
## Steps
|
|||
|
|
|
|||
|
|
1. Define `SyncEngine` struct in `mem-ingest/src/sync.rs`.
|
|||
|
|
2. Implement manifest loading/saving (JSONL per connector).
|
|||
|
|
3. Implement diff algorithm: `(new, changed, unchanged, removed)` from
|
|||
|
|
`list_documents()` vs manifest.
|
|||
|
|
4. Implement sync loop: for each `new`/`changed` doc, fetch → chunk → emit
|
|||
|
|
records. For each `removed` doc, emit tombstones.
|
|||
|
|
5. Implement drift reporting: same diff algorithm, print counts, no mutations.
|
|||
|
|
6. Implement resume: track synced doc_ids in a progress file. On restart,
|
|||
|
|
skip already-synced docs.
|
|||
|
|
7. Implement rate limiting: configurable max concurrent fetches per connector.
|
|||
|
|
8. Integrate with `mem-store` for writing Reference records to the log.
|
|||
|
|
9. Integrate with `mem-llm` for embedding new chunks.
|
|||
|
|
|
|||
|
|
## Acceptance
|
|||
|
|
|
|||
|
|
- Sync of unchanged connector produces zero embedding calls.
|
|||
|
|
- Changed document: old chunks tombstoned, new chunks embedded and stored.
|
|||
|
|
- Removed document: chunks tombstoned, manifest updated.
|
|||
|
|
- Drift report matches actual changes without mutating anything.
|
|||
|
|
- Crash mid-sync → restart processes only remaining documents.
|
|||
|
|
- Rate limiting: never exceeds configured concurrent fetch limit.
|
|||
|
|
- Log remains append-only (tombstones are records, not deletions).
|
|||
|
|
|
|||
|
|
## Verify
|
|||
|
|
|
|||
|
|
**Harness:** `VecConnector` with mutable document list, counting embedder.
|
|||
|
|
|
|||
|
|
**Integration test** — `tests/it_sync_framework.rs`:
|
|||
|
|
1. `a1_initial_sync_all_new` — 3 docs, no manifest; assert all 3 fetched and
|
|||
|
|
embedded, manifest written with 3 entries.
|
|||
|
|
2. `a2_unchanged_skipped` — sync again with same content; assert zero fetch
|
|||
|
|
calls, zero embed calls.
|
|||
|
|
3. `a3_changed_doc_replaced` — modify one doc's content; sync; assert old chunks
|
|||
|
|
tombstoned, new chunks embedded, embed count equals changed doc's chunk count.
|
|||
|
|
4. `a4_removed_doc_tombstoned` — remove a doc from connector; sync; assert
|
|||
|
|
tombstone records emitted, manifest entry removed.
|
|||
|
|
5. `a5_new_doc_added` — add a doc to connector; sync; assert only new doc
|
|||
|
|
fetched and embedded.
|
|||
|
|
6. `a6_drift_report_read_only` — modify docs, run drift report; assert correct
|
|||
|
|
counts (1 new, 1 changed, 1 unchanged, 1 removed); assert no mutations to
|
|||
|
|
manifest or log.
|
|||
|
|
7. `a7_resume_after_crash` — sync 5 docs, simulate crash after 3; restart;
|
|||
|
|
assert only 2 remaining docs processed.
|
|||
|
|
8. `a8_tombstone_is_append` — count log lines before and after remove; assert
|
|||
|
|
count only grew.
|
|||
|
|
9. `a9_manifest_roundtrip` — save manifest, load it; assert field-for-field
|
|||
|
|
equality.
|
|||
|
|
10. `a10_rebuild_parity` — after full sync, `mem rebuild --from-log` produces
|
|||
|
|
identical state.
|
|||
|
|
|
|||
|
|
**Command:** `cargo test --test it_sync_framework`
|
|||
|
|
|
|||
|
|
**False pass:**
|
|||
|
|
- Asserting "no duplicate rows" instead of counting embed calls. A sync that
|
|||
|
|
re-embeds everything and upserts by sha produces correct rows and wasted
|
|||
|
|
compute.
|
|||
|
|
- Testing drift report without actually changing documents first.
|
|||
|
|
|
|||
|
|
## Traps
|
|||
|
|
|
|||
|
|
- Comparing document-level hashes instead of chunk-level. A doc that changed
|
|||
|
|
one paragraph should re-embed only the affected chunks, not all of them.
|
|||
|
|
However, heading-boundary chunking means changing one heading can shift all
|
|||
|
|
subsequent chunks. Accept document-level granularity for now; chunk-level
|
|||
|
|
optimization is a future refinement.
|
|||
|
|
- Making the manifest a database table instead of JSONL. The manifest must
|
|||
|
|
survive `mem rebuild --from-log` — it is metadata about the sync process,
|
|||
|
|
not a projection of the log.
|
|||
|
|
- Running fetch + embed serially. A connector with 500 docs at 200ms per embed
|
|||
|
|
takes 100s serially. Concurrent fetch + sequential embed is the right shape.
|
|||
|
|
- Ignoring the derived filter (M4.2). Document connectors produce Level R content
|
|||
|
|
that must register in the artifact manifest so it cannot re-enter as evidence.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
Background: [DESIGN.md](../DESIGN.md) — source connectors, sync framework
|