ADR-0002: Derive IsLatest at read time#
Status#
Accepted — April 2026
Context#
S3 versioning requires every object row to expose an IsLatest bit:
the most recent (un-deleted) version of a key carries IsLatest=true,
every older version carries IsLatest=false. The naive
implementation flips the bit on the previous head whenever a new PUT
lands — UPDATE objects SET is_latest=false WHERE bucket_id=? AND key=? AND version_id=<prev> immediately after inserting the new
version row.
That approach has two costs on Cassandra:
- Write amplification. Every PUT becomes two LWT round trips — the insert of the new version, plus the flip of the previous head. On a write-heavy bucket the flip dominates p99.
- Coordination required to find the previous head. The flip needs the previous version-id, which is itself a scan or a cached-state read. Either path is a coherence hazard if concurrent PUTs race.
We could persist the previous head id alongside the new row to avoid the scan, but the write-amplification cost remains and the schema gets more brittle.
Decision#
We do not flip IsLatest on PUT. The bit is derived at read time
from the clustering order of the objects table:
PARTITION BY (bucket_id, shard)
CLUSTERING ORDER BY key ASC, version_id DESCThe version-id is encoded so that the lex-largest id sorts first
within the same key. The first row emitted for any key during a
range scan is therefore the latest version — IsLatest=true is
synthesised in the scan loop without consulting any persisted
column. ListObjects carries an in-memory dedupe pass (one
cursorHeap-per-key) that emits IsLatest=true once per key, then
IsLatest=false for the rest of its versions, all in a single
sequential scan.
For TiKV the same property is preserved by encoding the version
suffix as [MaxUint64 - ts8-BE][raw-uuid-16] (24 bytes total); the
inverted timestamp makes an ascending range scan emit the latest
version first. The null sentinel UUID (timestamp 0) sorts last
among versions of a key, so ?versionId=null resolves via exact
lookup, not scan-position arithmetic. See internal/meta/tikv/keys.md
for the full key-layout spec.
Consequences#
- Zero write amplification on PUT. A new version is one LWT insert. The previous head row is untouched.
- Schema invariant: clustering order is load-bearing. Both
Cassandra
CLUSTERING ORDER BY key ASC, version_id DESCand the TiKV version-suffix encoding are part of the public meta-store contract — they cannot be changed without rewriting the listing code path. Tests ininternal/meta/storetest/contract.goexercise the ordering against both backends. - List path carries the dedupe pass.
ListObjectscannot be a blind partition scan; it must merge versions per key. TheversionHeaplives incassandra/store.go; the TiKV backend short-circuits via its native ordered scan but still emits oneIsLatest=trueper key. - GET-without-versionId is free. Resolving the latest version
is a
LIMIT 1on the (bucket_id, shard, key) prefix — the first row hit is the answer. No “find current head” pre-query. - Delete-marker semantics fit naturally. A delete-marker is a
version row with
is_delete_marker=trueand the largest version-id of the key. The scan emits it asIsLatest, so GETs return 404 without a special case.