All notable, user-visible changes to konserve are documented here.
Monotonic write stamps — :last-write metadata is now issued by a
process-global monotone clock (utils/now = max(wall-clock, previous-stamp)): stamps never go backwards under wall-clock retreat
(NTP step-backs, VM suspend/resume, manual clock sets) — the stamp holds
at its high-water mark until real time catches up. This makes
konserve.gc/sweep!'s safety argument hold by construction: an object
written under a collector's guard can no longer be stamped before the
cutoff that protects it and be deleted while live. Deliberately
non-strict (no +1 per stamp): a strict clock would run ahead of
physical time above 1000 writes/second and stall collection after a
restart following a bulk import; same-millisecond ties remain possible
and are fail-safe (the sweep spares equality — garbage retained one
cycle, never a live deletion). Stamps still read as wall time (Date);
external collectors must obtain their cutoffs from
utils/now/utils/monotonic-now-ms (the same source) rather than raw
clock reads. Single-process only, as before.
PReadMissSafe marker protocol (konserve.impl.storage-layout). A backing
store implements it to declare that a read of an absent key is side-effect-free
and reports the miss cleanly — its -read-header throws
(store-key-not-found-ex store-key) on an absent key, with no side effect. When
a backing declares it, io-operation learns existence from the read itself and
skips the separate -blob-exists? probe, removing a redundant round-trip (an S3
HEAD before the GET). The default filestore deliberately does not
implement it — its -create-blob opens with CREATE and would materialise an
empty blob on a probe-free missing read — so filestore behaviour is unchanged.
New helpers: store-key-not-found-ex, store-key-not-found?,
store-key-not-found.
dissoc opt :ignore-existence?. dissoc normally probes with
-blob-exists? so it can return whether the key existed (true) or was absent
(false) — konserve's contract, enforced by the compliance suite. A caller that
does not need that boolean (e.g. a GC bulk sweep) can pass
{:ignore-existence? true} to skip the probe on a PReadMissSafe backing (whose
delete is idempotent), returning true. On non-miss-safe backings the hint is
ignored and the probe stays.
The IndexedDB backend implements PReadMissSafe. A browser read was two
IndexedDB transactions — .getKey (the -blob-exists? probe) then .get — and
is now a single .get (read-modify-write ops drop their .getKey too). Its
-create-blob is side-effect-free and read-blob now signals
store-key-not-found-ex on an absent key. (dissoc's single-key fast path also
honours :ignore-existence?; the multi-key GC delete path is a separate
follow-up.)
:BoringSerializer (serializer byte 3) — boring,
an RFC 8949 CBOR codec that runs on the JVM and ClojureScript from one
implementation. Unlike :CBORSerializer (byte 2, clj-cbor) it accepts read
handlers rather than throwing on them, and unlike :FressianSerializer it is
not JVM-only. Because the payload is standard CBOR, a store written by
Clojure can be read by any language with a CBOR library:
interop/read_konserve_blob.py is the whole format in one file, and
konserve.interop-python-test runs it against a real blob so it cannot drift
from what konserve writes.
:zstd compressor (compressor byte 2), via the optional dependency
com.github.luben/zstd-jni. Loaded reflectively so the native binaries stay
out of every user's dependency graph; when absent, byte 2 resolves to a
compressor that throws an actionable message instead of the namespace failing
to load. On one 512-datom blob zstd-3 was 23x faster than LZ4-HC and about
half the size, which is why :lz4 stays the fast compressor rather than
being switched to the high one.
zstd + aes failed with "Unknown frame
descriptor", lz4 + aes with "Stream unsupported". Both defaults are null,
and null is identity, so the order was invisible in every configuration the
tests covered — while the README documents compression and encryption
configured together. A new konserve.compressor-encryptor-matrix-test walks
every compressor × encryptor × serializer combination through a real store,
and asserts that a compressed+encrypted blob is smaller than an
encrypted-only one, which pins the order rather than just the round trip..flush rather
than .close, and LZ4FrameOutputStream.flush does not write the frame's
EndMark. Fressian never noticed, because it stops reading at the end of a
value; the CBOR serializer reads to EOF and hit the truncation with "Stream
ended prematurely". A compressor bug that only one serializer could see,
which is why the matrix runs over both.native-image-build? tested only whether org.graalvm.nativeimage.ImageInfo
was on the classpath — and that class ships with every GraalVM JDK, so an
ordinary JVM run on GraalVM took the native-image path: NPE on write, throw
on read. It now asks ImageInfo/inImageCode, which is the question that was
meant.:frontend-only tiered store no longer deletes the shared backend. -delete-store :tiered deleted the backend unconditionally. Under :write-policy :frontend-only the
store is a read-through cache over a backend that another peer OWNS and that this
one must never write — deleting is the most destructive write there is, so a cache peer
calling delete-store (or Datahike's delete-database) would take the authoritative
data with it. It now deletes only its own cache; under every other policy the store owns
its backend and both tiers go. This was latent while tiered delete silently did nothing
(see below) — fixing the missing await made it reachable.delete-store-async was broken in three ways, and never ran.
Wiring -delete-store :file to the async variant (above) exposed it. iofs/arm-r
yields [?err] — a vector — but it was bound as a bare ?err, so the success
value [nil] was truthy and the function always took the error branch: it
returned [nil] as if it were an error and never reached the fsync at all. Once
that was fixed, two more surfaced: it fsynced base — the directory arm-r had just
deleted — where the sync twin correctly fsyncs the parent; and sync-base-async
called .force on the result of open-async-file-channel without checking whether
it was an Error. It now returns nil on success and the error on failure, matching
the sync delete-store.delete-store now honours :sync? — and :tiered actually deletes.
-delete-store was the one store method that ignored its opts: :memory and
:file (JVM and Node) returned a plain value whatever :sync? said, so an async
caller could not await the deletion — and delete-store defaults to
{:sync? false}, so async is the common path. Worse, :tiered called
(delete-store backend-config) with no opts — the async default — and then
dropped the returned channel, so deleting a tiered store over an async backend
(e.g. S3) removed nothing at all, silently, with any error swallowed into a
channel nobody read. All four implementations now follow the same contract every
other store method obeys: a value under {:sync? true}, otherwise a channel that
delivers when the deletion is complete. The Node file backend now also uses its
existing non-blocking delete-store-async on the async path.
The contract is documented on the -delete-store multimethod and pinned by tests
(previously memory-store-delete asserted the broken behaviour).PReadMissSafe backing, update-in / update / nested assoc-in / bassoc
read the old value read-first (an absent key → a fresh write) instead of a
HEAD probe followed by the read. A read-modify-write on an existing key drops
from HEAD + GET + PUT to GET + PUT. Pure reads (get / bget /
get-meta) were already a single GET on a miss-safe backing.konserve.gc/sweep! passes :ignore-existence? on its single-key delete
fallback, so GC on a miss-safe store deletes each dead key without a per-key
HEAD probe (the batch multi-dissoc path was already probe-free).Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |