Sync engine metadata grows to ~10× the user database #535
Replies: 2 comments 3 replies
|
Hi @KayLeung, thanks for the report. I'm not sure how to square this though:
Compressing just Further, in the first image, which compression image is being used? |
|
the extra comes from the SQLite overhead (see point 4) I re-test them again. For a open source library, go with no Dict by default is safer.
Compress and compact the CloudKit metadatabaseThe SQLiteData CloudKit sidecar reaches 11.7× the size of the database it Measured 2026-08-28 against a real Contents
The problem
Nothing is wrong with the schema.
That is 7.9 KB of sync bookkeeping per record, against roughly 700 bytes of How it was measuredEvery number in the results table is a real file size, not an estimate. For
Step 4 is why the baseline row reads 852.7 MB against the 832.2 MB file as found: Roundtrip verification differs by technique, and the difference matters:
ResultsSorted by file size, largest first. Ties are broken by allFields payload.
¹ compact — replace ² key-prefix — not a separate technique, but one flag inside that encoder Rows ¹ and ² are therefore the same code path with one boolean flipped, and they
Single-column compression variants are omitted from the table: all of them The smallest file costs the most to keep. Adding key-prefix encoding reaches If the dictionary is rejectedThe dictionary carries real integration cost: a dictionary table, append-only Plain zlib is reachable from One caveat before anyone re-runs this: the 237.6 MB figure was measured with libz
Most of that 155 MB gap is one column: sys stays at 130.6 MB without a The dictionary is worth 155 MB, a further 2.9× — but 237.6 MB is already a Why the file size does not track the payloadThree results look wrong until you account for SQLite page geometry:
All three are one mechanism. SQLite splits a large row: Leaf-local sizes below are derived from SQLite's split rule; page counts are
Compressing sys alone takes the leaf-local part from 3858 to 2458 bytes — still
Only when both columns come down does the local part fall under ~1000 bytes
lzfse and zlib shrink the leaf side 3–5× and remove no overflow pages at all. Among the compression-only variants, dictionary zlib is the only one that brings The threshold can decide between two codecsThe sharpest case is
Two rows share a 4084-byte leaf page only if each is under ~2042 B, counting the
zlib puts every row under the line and packs two per page. lzfse leaves 98.6% of The codec ranking is therefore not stable across datasets. On records One consequence: the win is threshold-shaped and does not extrapolate. An app Technique 1 — dictionary compressionBoth blobs are
Dictionary zlib is also faster than plain zlib — 61.0 vs 64.9 µs encode, lzfse is the obvious default, and it is the wrong one here. It is Apple's It is also worse than plain zlib for a reason that has nothing to do with lzfse cannot take a dictionary. Level barely matters. Level 9 buys 0.04 pp over level 6 for double the CPU; Dictionary size matters more, and has a floor and a ceiling. A dictionary Construction matters more than either knob. Packing whole record archives and Technique 2 — compactionA typical allFields blob is 6075 bytes, of which only 1169 are strings. The rest 81% of the blob is scaffolding. Every value is a Compaction replaces the archiver with a type-length-value encoding: each The same record, compacted: 13 value fields and 19 per-column modification-time keys. Field names are 6075 B → 1421 B values-only, → 751 B with key-prefix encoding. Technique 3 — key-prefix encodingThree marker bytes carry the key:
So a It halves the payload and is worth almost nothing. Measured in all three
Uncompressed and with plain zlib the file does not move at all. With the
Payload falls 66.6 MB and unused space rises by exactly 66.6 MB. With sys still
DesignDictionaries: self-trained, in the metadatabase, append-onlyThe dictionary is required to decompress — deflate encodes matches as A dictionary hardcoded into the library would be wrong, because it is trained on CREATE TABLE sqlitedata_icloud_recordDictionaries (
recordType TEXT NOT NULL,
kind INTEGER NOT NULL, -- 0 = sys, 1 = allFields
version INTEGER NOT NULL,
dictionary BLOB NOT NULL,
PRIMARY KEY (recordType, kind, version)
) STRICT;Each blob carries a 4-byte header — Keeping dictionaries in the same file as the rows they decode — not a shipped The first ~60 records of a record type sync uncompressed. Once that many exist, Behaviour under schema changeNeither technique needs existing rows rewritten, but for different reasons.
The dictionary is trained on archived The type-length-value encoding is self-describing: the keys in the blob are the Neither is app-specific. The only constant in the encoding is The gap that must close before this shipsValue type coverage. The prototype encoder handles Scanning all 100,710 records in this sidecar: Only three types — no The fix is a per-record escape rather than exhaustive type handling: if any value |


Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The sidecar sync meta DB is 757MB compared to 70MB for the production DB.
I had AI research this, and it suggests custom key-prefix encoding + zlib compression as a fix (e.g. shortening/removing repeated prefixes like sqlitedata_icloud_userModificationTime_).
some quick benchmark( ~70MB DB )
Full DB:

Partial:

For reference, SwiftData compresses its store CKRecord with LZFSE.
I'd like to find a fix upstream, or a hook point that lets us apply this without forking the project.
Thanks.
All reactions