docs: plan adaptive journal synchronisation

This commit is contained in:
vorotamoroz
2026-07-31 12:53:50 +00:00
parent b21c3114e5
commit a275499c1a
+520
View File
@@ -0,0 +1,520 @@
# [PLANNING] Adaptive Journal Sync
> [!NOTE]
> This document is an exploratory developer design. It is not a design freeze, release commitment, migration
> instruction, or statement of current user-visible behaviour. The existing opaque Journal pack protocol remains the
> implementation baseline while this proposal is evaluated.
## Summary
Adaptive Journal Sync would separate lightweight Metadata events from Chunk delivery while preserving one common
Journal replication contract across different remote capabilities.
The logical model would always treat Chunks as content-addressable, immutable values. The physical representation would
adapt to the remote:
- a batch-capable database such as PostgreSQL exposed through PostgREST could store one encrypted Chunk per immutable
row and answer multi-key requests through one RPC; and
- an object-shaped remote such as S3-compatible Object Storage or WebDAV could aggregate Chunks into immutable packs,
then resolve Chunk identifiers through an append-only, versioned catalogue.
This proposal deliberately separates common semantics from common physical layout. A remote does not need to store
Chunks in the same shape as another remote, but every implementation must provide the same batched availability,
storage, retrieval, publication, and failure guarantees.
## Context
Self-hosted LiveSync already separates each file into one Metadata document and multiple Chunk documents. Metadata
contains file properties and Chunk references; it does not contain raw file content.
The current Journal Replicator serialises Metadata and Chunk document changes into the same compressed and optionally
encrypted Journal pack. `IJournalStorage` then transports each pack as an opaque key and byte sequence. This design is a
useful compatibility baseline because S3-compatible Object Storage, WebDAV, and PostgREST exercise the same Journal
logic.
The common opaque representation also prevents a storage adapter from making Chunk-specific decisions. By the time an
adapter receives a pack, Metadata and Chunks have already been combined, compressed, and encrypted. PostgREST can
provide indexed Journal listing, but it cannot use native batched Chunk lookup or relational transactions at this
boundary.
Adaptive Journal Sync proposes a domain-level repository boundary above physical object storage.
## Relationship to existing designs
This proposal builds on, rather than silently replaces, the following documents:
- [The Journal Replicator 2nd Edition](../design_docs_of_journalsync_2nd.md) defines the current streaming pipeline and
`IJournalStorage` boundary.
- [The design intent explanation for using metadata and chunks](intention_of_chunks.md) records why Metadata and Chunks
are separate and why Chunks are content-addressable.
- [Tiered Chunk Storage with Live Compaction](tired_chunk_pack.md) and
[Tiered Chunk Storage for Bucket Sync](tired_chunk_pack_bucket.md) establish the hot/cold and LSM-tree motivation.
- [Chunk Aggregation by Prefix](chunk_aggregation_by_prefix.md) is withdrawn, but records the risks of mutable shared
index documents and write-conflict retry loops.
- [Chunk Retrieval and Waiting](chunk_retrieval_and_waiting.md) defines the current delivery and terminal-condition
contract which any new direct Chunk source must extend deliberately.
Adaptive Journal Sync refines the earlier tiered proposals by replacing mutable per-client hot logs and shared mutable
index documents with immutable Level 0 segments, append-only catalogue deltas, and periodic catalogue snapshots.
## Goals
- Keep Metadata and raw file content separate throughout remote publication.
- Preserve logical content-addressable storage for Chunks.
- Bound network requests by batches or packs rather than by individual Chunk count.
- Use native multi-key RPCs and transactions where a remote provides them.
- Use immutable pack files and catalogue records where a remote provides only object operations.
- Keep ordinary multi-writer publication append-only and free from a central mutable write bottleneck.
- Publish referenced Chunks before publishing Metadata which makes those references visible.
- Retain end-to-end encryption and document any metadata or equality leakage introduced by indexed Chunk lookup.
- Allow the current opaque Journal protocol and the proposed protocol to coexist behind an explicit version boundary.
- Make every derived index and cache reconstructible from authoritative remote records.
## Non-goals
- Replacing Metadata with a server-readable relational file schema.
- Storing raw file content inside Metadata rows or events.
- Requiring every remote to expose the same physical layout.
- Guaranteeing that the same logical Chunk has exactly one physical copy at every moment.
- Introducing an implicit migration or rebuild for existing Journal remotes.
- Making Garbage Collection safe without an explicit reachability, retention, and device-progress policy.
- Committing this design to a release before correctness, privacy, and performance gates have passed.
## Terminology
### Logical Chunk
An immutable content value identified by a Chunk ID. If bytes change, the result is a new logical Chunk with a new ID.
An existing logical Chunk is never updated in place.
### Native CAS
A remote representation in which one logical Chunk maps to one insert-only value addressable by an opaque Chunk key.
PostgreSQL is the initial candidate because it can answer `hasMany`, `getMany`, and `putMany` through bounded batch RPCs.
### Chunk pack
An immutable physical object containing one or more independently verifiable encrypted Chunk records. A pack is a
request-amortisation and compaction unit, not the logical identity of its Chunks.
### Catalogue
The reconstructible mapping from opaque remote Chunk keys to one or more physical pack locations. Its authoritative
object-storage representation consists of immutable snapshots and append-only deltas. A local in-memory or persistent
catalogue may be mutable because it is derived state.
### Metadata batch
An immutable group of Metadata revision events. It refers to logical Chunk IDs and contains no raw Chunk content.
### Commit manifest
The final publication record for a Metadata batch. A reader ignores an incomplete batch until its commit manifest is
visible and valid.
## Required invariants
1. Metadata contains Chunk references, never raw file content.
2. A logical Chunk ID identifies immutable bytes.
3. A committed Metadata reference is not published before at least one valid remote source for that Chunk is durable.
4. Ordinary writers create immutable records. They do not edit an existing pack, catalogue snapshot, or catalogue
delta.
5. A retry may create a duplicate physical Chunk or pack, but it must not create two logical meanings for one Chunk ID.
6. A receiver validates identity and authenticated encryption before accepting a Chunk.
7. A derived catalogue, cache, or PostgreSQL materialised index can be discarded and reconstructed.
8. Repacking does not require Metadata revision changes because Metadata refers to logical Chunk IDs rather than pack
locations.
9. Remote reset, protocol migration, compaction, retirement, and deletion remain explicit protected operations.
10. The protocol must define a bounded completion or failure condition for each requested Chunk batch.
## Proposed repository boundary
`IJournalStorage` remains useful as the current opaque-object transport. Adaptive Journal Sync would introduce a
higher-level repository composition:
```ts
interface JournalEventStore {
appendMetadataBatch(batch: EncryptedMetadataBatch): Promise<BatchCommit>;
listMetadataBatches(after: BatchCursor, limit: number): Promise<EncryptedMetadataBatch[]>;
}
interface ChunkStore {
capabilities(): ChunkStoreCapabilities;
hasMany(ids: readonly RemoteChunkKey[]): Promise<ChunkAvailability>;
putMany(chunks: readonly EncryptedChunk[]): Promise<ChunkPublication>;
getMany(ids: readonly RemoteChunkKey[]): Promise<ChunkResult>;
}
interface AdaptiveJournalRepository {
events: JournalEventStore;
chunks: ChunkStore;
}
```
The interface is batched even when the physical remote lacks a native batch operation. The implementation owns request
planning and must not make the caller loop over one network request per Chunk.
Capability selection should describe semantics rather than provider names. Candidate capabilities include:
- bounded native multi-key lookup;
- bounded native multi-value read and write;
- atomic batch publication;
- conditional object writes;
- byte-range reads;
- inexpensive ordered listing; and
- server-side immutable CAS insertion.
The first implementation can use a fixed strategy per adapter. Dynamic thresholds may be added only after measurements
show that they improve the same contract.
## Physical strategies
### Native batch CAS
PostgREST can expose a Vault-scoped, row-level-security-protected Chunk table:
```sql
create table chunks (
vault_id text not null,
chunk_key text collate "C" not null,
encrypted_body bytea not null,
size_bytes bigint not null,
created_at timestamptz not null,
primary key (vault_id, chunk_key)
);
```
The public API would use bounded RPCs rather than one ordinary REST request per row:
- `has_chunks(keys[])` returns a compact availability result;
- `get_chunks(keys[])` returns a framed or streamed binary batch;
- `put_chunks(keys[], bodies[])` inserts missing immutable rows and verifies existing identities; and
- `commit_metadata_batch(...)` publishes the Metadata batch only after its required Chunks are present.
The exact request limits must be byte-based as well as count-based. A request containing hundreds of small Chunks and a
request containing hundreds of large Chunks do not have equivalent memory or proxy cost.
PostgreSQL may maintain mutable derived indexes transactionally. The Chunk rows themselves remain immutable CAS values,
and any separately maintained operational index must be rebuildable from authoritative rows and batch commits.
### Immutable pack CAS
S3-compatible Object Storage and WebDAV do not normally provide multi-key value retrieval. Storing one remote object per
Chunk would turn a missing set of 1,000 Chunks into as many as 1,000 existence requests and 1,000 value requests.
Parallel HTTP requests reduce elapsed time but do not remove request cost, connection pressure, or service charging.
The pack strategy groups newly published Chunks into immutable objects:
```text
packs/<pack-id>.bin
indexes/<pack-id>.idx
catalogue/deltas/<delta-id>
```
The pack index maps each opaque remote Chunk key to its ciphertext offset, ciphertext length, and integrity information.
S3-compatible storage can use byte-range reads when the pack format encrypts and authenticates records independently.
WebDAV can fetch the complete pack when reliable range reads are unavailable. Both paths expose the same batched
`getMany` result.
Pack targets must be expressed primarily in bytes. The initial experiment should compare small immutable Level 0
micro-packs with larger compacted Level 1 packs. A latency-sensitive synchronisation may publish a pack containing one
Chunk; it never rewrites an older pack merely to fill free space.
### Strategy comparison
| Property | Native batch CAS | Immutable pack CAS |
| --- | --- | --- |
| Logical identity | Opaque remote Chunk key | Opaque remote Chunk key |
| Ordinary write | Insert missing rows | Upload a new pack and index |
| Multi-key lookup | One bounded RPC | Local catalogue lookup |
| Multi-key read | One bounded or streamed RPC | Group by pack, then range or whole-pack reads |
| Physical duplicates | Preventable transactionally | Permitted temporarily |
| Ordinary shared mutation | Transactional derived index only | None |
| Compaction | Optional row maintenance | Repack live Chunks into a new generation |
## Pack lifecycle
A Chunk inside a pack is never changed. A file edit which replaces logical Chunk `B` with logical Chunk `D` produces a
new pack only for newly published content:
```text
Old Metadata: [A, B, C]
New Metadata: [A, D, C]
pack-001: [A, B, C] unchanged
pack-002: [D] new Level 0 pack
```
`B` may become unreachable, but `pack-001` remains available until a later protected compaction proves that it can be
retired. This trades temporary storage amplification for immutable, low-contention writes.
Small urgent writes may use either a one-batch micro-pack or a loose immutable object. Supporting both loose and packed
locations complicates the catalogue, so the first object-store experiment should use micro-packs consistently and add
loose objects only when measurements justify them.
## Versioned catalogue
### Authoritative state
The remote object-store catalogue is logically versioned but not rewritten in full for every ordinary write:
```text
catalogue/snapshots/<generation>
catalogue/deltas/<writer-id>/<delta-id>
catalogue/commits/<commit-id>
```
A delta can register a new pack:
```json
{
"add": {
"packId": "pack-002",
"index": "indexes/pack-002.idx"
}
}
```
The index contains the Chunk-to-location entries, so a catalogue delta stays small. Concurrent writers publish
independent immutable deltas; their additions merge as a set and do not compete to replace one shared catalogue object.
The same logical Chunk may temporarily resolve to several valid packs.
### Local derived catalogue
A client loads the latest trusted snapshot it knows, applies later deltas, and builds a mutable local mapping:
```text
Remote Chunk key -> [pack ID, offset, length, generation]
```
This local catalogue is a cache. Corruption or loss causes reconstruction, not remote data loss.
An optional small `catalogue/latest` object may point to a recent snapshot. It is an optimisation hint rather than the
sole authority. S3-compatible storage can update it with an ETag precondition. A WebDAV implementation which cannot
rely on conditional updates can recover by listing valid snapshots and catalogue commits.
### Snapshotting
Catalogue deltas eventually make new-device initialisation expensive. A compactor periodically produces a complete
immutable snapshot:
```text
snapshot-100 + deltas 101..500 -> snapshot-500
```
The snapshot commit records its covered delta frontier, integrity hash, format version, and predecessor information.
Readers apply deltas outside that frontier. Old snapshots and covered deltas remain available for a grace period before
protected deletion.
## Publication protocol
### Object-store publication
1. Build and validate a Chunk micro-pack locally.
2. Upload the immutable pack under a unique ID.
3. Upload its immutable index.
4. Publish an immutable catalogue delta which registers the pack.
5. Upload the encrypted Metadata batch.
6. Publish the batch commit manifest last.
7. Advance the local send checkpoint only after the commit manifest succeeds.
A failure before step 6 may leave unreachable objects, but it does not publish Metadata with unavailable Chunk
references. A retry can reuse verified immutable objects or publish replacements under new IDs. Later maintenance can
remove unreachable incomplete publications.
### Native CAS publication
PostgreSQL can perform Chunk insertion, required-Chunk verification, Metadata batch insertion, and commit publication
inside a transaction. The external semantics remain equivalent to the object-store commit-manifest sequence.
### Receive
1. List committed Metadata batches after the local cursor.
2. Decrypt and validate Metadata events.
3. Collect all referenced Chunks missing from the local database.
4. Call `getMany` once per bounded byte and count window.
5. Let the Chunk Store group object-store requests by pack or issue a native batch RPC.
6. Validate and persist Chunks.
7. Apply Metadata revisions through the maintained PouchDB revision contract.
8. Advance the receive checkpoint only after the batch reaches its terminal state.
A commit manifest may include non-authoritative location hints for newly published Chunks. Metadata continues to refer
only to logical Chunk IDs so that repacking never changes file revisions.
## Concurrency and failure model
Ordinary publication is multi-writer and append-only:
- pack IDs, delta IDs, and batch IDs must be globally collision-resistant;
- concurrent publication of the same logical Chunk may create physical duplicates;
- catalogue additions merge without a last-write-wins replacement;
- failed uploads remain unreachable until maintenance removes them; and
- retries are idempotent at the logical Chunk and Metadata revision boundaries.
Physical retirement and deletion require stronger coordination than addition. A compactor must use a bounded lease or
fencing token appropriate to the remote. A stale compactor may upload a redundant replacement pack, but it must not
publish a retirement or delete an object after losing its authority.
The protocol must test each failure boundary independently: pack upload, index upload, catalogue delta publication,
Metadata upload, commit publication, checkpoint persistence, snapshot publication, retirement, and deletion.
## Encryption and privacy
The current whole-pack encryption can hide document IDs, Chunk IDs, and pack contents from the storage service. Native
CAS and searchable pack indexes introduce different leakage, so they require an explicit threat-model review.
The remote Chunk key should be an opaque Vault-scoped derivation rather than a raw content hash. One candidate is:
```text
remoteChunkKey = HMAC(vaultChunkIdentityKey, localChunkID)
```
This prevents correlation of the same content across independent Vaults while preserving equality and deduplication
inside one Vault. It still reveals within-Vault equality, object count, sizes, and access patterns to the remote.
For byte-range pack reads, each Chunk record must be encrypted and authenticated independently. Encrypting one complete
pack as a single AEAD value would require downloading the complete pack before authenticating or decrypting one range.
The pack header and index must be authenticated, and the design must define whether the object-store catalogue itself
is encrypted.
PostgREST needs server-visible opaque keys to execute a multi-key query. It does not need plaintext file paths, local
Chunk hashes, or decrypted content.
Credential rotation must not change remote Chunk identity. Rotation of the Vault Chunk identity key is a data migration
which creates a new namespace and cannot be treated as an ordinary bearer-token or password change.
## Garbage Collection and repacking
Garbage Collection is a reachability operation, not an age-only deletion policy.
At minimum, the reachability set must protect the current Metadata winner and every live conflict branch. The treatment
of non-live retained revision history must be an explicit shared decision with the maintained Garbage Collection
specification. A design which silently makes retained history unreadable is not acceptable.
Object-store compaction follows a generational copy-and-publish process:
1. Freeze a reachability and catalogue frontier under a valid fencing token.
2. Read live Chunks from selected Level 0 and Level 1 packs.
3. Write new immutable Level 1 packs and indexes.
4. Publish catalogue additions for the new packs.
5. Publish a new snapshot or retirement delta which prefers the new generation.
6. Retain old packs for the defined device-progress and time-based grace conditions.
7. Delete retired packs only after the protection conditions remain satisfied.
Metadata does not change during repacking because the catalogue provides indirection from logical Chunk IDs to physical
locations.
Native row CAS can delete unreachable rows without repacking, but it must use the same reachability and retention
policy. PostgreSQL transactionality simplifies mutation; it does not prove that a Chunk is safe to delete.
## Compatibility and migration
Adaptive Journal Sync requires an explicit protocol and capability version recorded in the remote milestone or its
successor. A device which understands only opaque Journal packs must not partially consume Metadata-only batches.
The existing remote layout and the adaptive layout should use disjoint prefixes or schemas during experimentation. An
opt-in test Vault may rebuild into the adaptive representation, but an ordinary upgrade must not migrate or delete an
existing remote implicitly.
The compatibility plan must define:
- negotiation between old and adaptive clients;
- rollback before and after the first adaptive commit;
- coexistence or exclusion rules for mixed client versions;
- checkpoint identity and epoch changes;
- remote reset behaviour;
- encryption-key and Chunk-identity-key migration; and
- exact conditions which require Fetch, Rebuild, or a new remote profile.
## Performance model
Adaptive selection must optimise total work rather than request count alone. Measurements need to include:
- number of network requests;
- transferred and over-fetched bytes;
- first-Metadata and first-complete-file latency;
- CPU, memory, compression, and encryption cost;
- remote storage amplification;
- catalogue initialisation and update cost;
- PostgREST query, transaction, and response framing cost;
- S3-compatible request charging and range behaviour; and
- WebDAV listing, range support, and whole-pack fallback.
The benchmark matrix should vary Vault size, retained history, Chunk-size distribution, editing pattern, network
round-trip time, proxy path, concurrent writers, and new-device versus incremental synchronisation.
Initial thresholds must be conservative and observable. Candidate inputs include pending byte count, pending Chunk
count, expected pack reuse, range-read capability, and an upper bound on one RPC or pack response. Provider names alone
must not become performance policy.
## Planned implementation stages
### Stage 0: Executable model
- Define the repository, Chunk Store, catalogue, and commit state machines independently of a transport.
- Add property and fault-injection tests for publication ordering, retry, duplicate physical locations, and recovery.
- Record the threat model and remote-visible metadata.
### Stage 1: Native batch CAS experiment
- Add disposable PostgreSQL/PostgREST RPCs for batched Chunk availability, insertion, retrieval, and Metadata commit.
- Measure RPC framing, transaction size, memory, and proxy limits.
- Verify Vault isolation and failure rollback through real service integration tests.
### Stage 2: Immutable object-pack experiment
- Define a versioned, independently encrypted Chunk-record pack format.
- Implement catalogue deltas and a local derived catalogue.
- Compare S3-compatible range reads with WebDAV whole-pack fallback.
- Validate bounded request behaviour without per-Chunk `HEAD` or `GET` loops.
### Stage 3: Snapshot and compaction
- Add catalogue snapshots, retirement records, fencing, grace conditions, and recovery.
- Implement conservative repacking without Metadata rewrites.
- Exercise concurrent writers and interrupted compactors.
### Stage 4: Consumer integration
- Extend Chunk retrieval with an explicit Adaptive Journal producer and terminal-condition contract.
- Add multi-device Commonlib integration, CLI consumption, and focused real-Obsidian tests.
- Define opt-in migration, rollback, diagnostics, and maintenance controls.
### Stage 5: Release decision
- Compare the adaptive paths with the current opaque Journal baseline.
- Review privacy, corruption recovery, storage amplification, and operational complexity.
- Select supported strategies and thresholds only after the evidence is available.
## Test obligations
- Metadata is never committed before all newly required Chunks have a durable source.
- One logical Chunk ID never resolves to different accepted plaintext bytes.
- Batch retry, duplicate physical storage, and concurrent publication are idempotent.
- Native CAS batch limits are enforced by count and bytes.
- Object-pack retrieval groups requests by pack and avoids per-Chunk network loops.
- Range and whole-pack retrieval produce identical validated Chunk results.
- Catalogue reconstruction from snapshots and deltas is deterministic.
- A stale snapshot hint cannot hide later valid deltas.
- Interrupted snapshotting, repacking, retirement, and deletion preserve readable data.
- Every protected Metadata winner and live conflict branch remains reconstructible.
- Direct Chunk delivery integrates with the maintained waiting and finite-activity boundaries.
- Vault-scoped access and opaque remote Chunk identity prevent cross-Vault retrieval.
- Mixed protocol versions fail closed before publishing incompatible Metadata.
## Open decisions
- Which Vault secret, derivation, and rotation contract should produce remote Chunk keys?
- Is the object-store catalogue encrypted, and which non-secret header remains listable?
- What exact pack framing supports independent authentication and efficient range reads?
- Should a small urgent publication always be a micro-pack, or may it use a loose immutable Chunk object?
- How are catalogue delta frontiers represented without relying on wall-clock ordering?
- Which conditional-write, lease, or fencing mechanisms form the portable minimum across S3-compatible storage and
WebDAV?
- What reachability and retention policy protects non-live revision history?
- What payload and response framing lets PostgREST stream large bounded batches safely?
- When does PostgreSQL `bytea` remain appropriate, and when should a PostgREST deployment use an external object store?
- Which benchmark results justify adaptive thresholds rather than one fixed strategy per remote?
These decisions remain Planning work. No implementation stage should silently choose them through incidental adapter
behaviour.