Getting started
Install the extension
Section titled “Install the extension”Moraine is not yet in the DuckDB community extension repository, so the
extension loads unsigned. Grab moraine.<platform>.duckdb_extension (e.g.
moraine.osx_arm64.duckdb_extension) from the latest ext-v* entry on the
releases page, then start
DuckDB with -unsigned (the setting cannot be changed on a running
database):
Once moraine is published as a community extension this becomes
INSTALL moraine FROM community; LOAD moraine;.
Attach a lake
Section titled “Attach a lake”A local lake needs nothing but paths:
Pass both path options, with the same value. DATA_PATH is DuckLake’s:
where Parquet files go. META_DATA_PATH is forwarded to moraine and
recorded in the catalog at bootstrap — after that, re-attaches can omit
DATA_PATH entirely, and operations that need the data root (like indexing
a table that already holds data) work. The recorded value is fixed: an
attach supplying a conflicting META_DATA_PATH is refused rather than
silently diverging.
S3 lakes need READ_WRITE
Section titled “S3 lakes need READ_WRITE”DuckDB opens any attach whose path starts with a remote prefix (s3://,
gcs://, azure://, …) read-only by default, and a read-only attach
cannot create a catalog. To create or write a lake on S3, say READ_WRITE:
Once the lake is bootstrapped, a later writer attach needs only the flag — the data path is served back from the catalog:
One writer, many readers
Section titled “One writer, many readers”A moraine lake is single-writer, many-readers. The selector is DuckDB’s standard attach flag — no moraine-specific grammar:
- Read-write (the default for local paths) opens the one SlateDB writer. Exactly one process should attach read-write at a time.
READ_ONLYopens a follower that serves consistent snapshots and tracks the writer’s commits, and never becomes a writer:
Take the one-writer rule seriously: SlateDB fencing means the newest
writer wins, so a second read-write attach doesn’t fail — it fences the
incumbent’s committer. Every process past the first should attach
READ_ONLY.
Commit latency
Section titled “Commit latency”A commit is durable once one write-ahead log PUT reaches object storage, and that PUT is the whole latency floor: everything else about a commit is local work. Two attach options set how long a commit waits for it, and a third moves where it lands.
META_FLUSH_INTERVAL_MS is the minimum spacing between two log
flushes, 100 ms if unset. A commit that arrives past the spacing flushes
at once and waits only on its own PUT; one that arrives inside it joins a
single flush deferred to the moment the spacing elapses. So a lone commit
costs one PUT, a burst costs at most the spacing plus a PUT, and the PUT
rate never exceeds one per spacing however many commits arrive:
Shorten it for a low commit rate that wants latency; leave it for a high
one, where the spacing is exactly what merges many commits into one
request. META_FLUSH_ON_COMMIT true is the same setting at zero: every
commit flushes on its own, one PUT each.
That leaves the PUT itself, and it need not go to the bucket the catalog
lives in. META_WAL_PATH writes the log — and nothing else — to a store of
its own, which is what lets an S3 Express One Zone directory bucket serve
the one latency-critical write while standard storage holds the rest:
The log store resolves its own s3 secret, since it is a different bucket
and a different endpoint, and a bucket named with the suffix AWS reserves
for directory buckets (--x-s3, --xa-s3) is addressed as S3 Express with
nothing further to set. It must be as durable as the catalog bucket: a
commit no sorted-string table holds yet lives only there.
Where a lake keeps its log is recorded in the lake, so every later attach names the same store, readers included. One that names a different store, or none, is refused rather than replaying an empty log over live state — and the refusal names the store the lake records, which is how to find out what it is. Moving an existing lake’s log is a verb, run with the lake attached nowhere:
It drains the log where it stands before recording the new store, so
nothing in flight is lost; pass from_wal_path => '…' when the lake
already has a log store of its own, and wal_path => NULL to bring the log
back into the catalog bucket.
Faster repeat queries on S3
Section titled “Faster repeat queries on S3”moraine keeps one block cache per process, shared by every store the process attaches. It holds SST metadata — the indexes and filters every lookup walks first — in memory, and data blocks in memory over an optional disk tier.
Give it a directory and the data blocks spill there, so a warm working set survives more than memory alone would hold:
Two byte counts size it, both for the whole process rather than per store:
META_CACHE_MEMORY caps memory across both halves, META_CACHE_SIZE caps the
directory (16 GiB if unset).
META_CACHE_MEMORY is the number to weigh against DuckDB’s own memory_limit
when sizing a host: DuckDB’s budget covers its buffers and its Parquet cache,
and this covers moraine’s process caches — SST metadata, data blocks, and
the parsed Parquet metadata equality-index upkeep reads. Each attached
catalog additionally holds its own decoded view, which no option bounds
because its size is the catalog’s; projection_bytes on a catalog handle
reports it.
SST filters and indexes share that budget with data blocks, and every point read walks them before it can reach a block. They are not given a fixed slice: metadata is evicted only once no data block is left to evict, so a scan cannot push it out, and metadata a store does not need is space blocks may use. What is capped is how much stays protected — most of the budget.
A store whose metadata outgrows that turns each probe into an S3 fetch, worst for keys that are absent, which consult every filter on the path and touch no data block at all. The attach warns when it happens, naming the shortfall:
Raise META_CACHE_MEMORY on the first attach in the process — the cache
is process-wide and the first attach builds it. moraine_store_census
reports the same filter and index bytes per subspace if you want to see
where the weight sits before setting a number.
By default the cache fills only as queries read, so the indexes and filters
of an SST the writer just flushed are fetched back from S3 the first time
something reads them. META_CACHE_PUTS true admits them as they are written
instead — metadata only, since a data block the writer produced is not one a
reader is likely to want next:
It is off by default because compaction output goes through the same policy, and a large merge can evict what queries had warmed.
A fresh process still starts cold, and the first query pays every first touch.
META_CACHE_PRELOAD moves that cost into the ATTACH — 'l0' warms every
subspace’s SST metadata, 'all' additionally reads the catalog subspaces
whole, 'none' (the default) warms nothing:
Neither level pulls the index subspace’s data blocks, which on an
index-carrying store is most of its bytes — so 'all' costs metadata-sized
reads, not store-sized ones, and equality lookups stay one fetch per block
behind the filters it just warmed. moraine_store_census reports how the
bytes are distributed.
The first attach in a process sizes the cache; later attaches share what it built. On a host that attaches several catalogs, set these on the first one.
moraine_cache_tally() reports what that cache has served — hits and misses
per slot, with a rate beside each — so the budgets can be set from measured
curves rather than the defaults. Since one cache serves every attach, those
numbers are the host’s; pass a lake name to see one attach’s share of them:
A rate is NULL rather than zero before anything has been looked up. On a host attaching several catalogs, the per-lake form is what says which of them is spending the budget the process-wide form reports.
Caching the data files
Section titled “Caching the data files”The cache above is the catalog’s. Parquet data files are read by DuckDB itself, and moraine never touches a data byte or adds a cache of its own for them.
DuckDB caches them for you. A lake read goes through DuckDB’s built-in
external file cache, so the data ranges a query fetches are held under
memory_limit and a repeat read of the same bytes costs no storage
request at all. One caveat worth knowing when you measure it: data-range
caching rides on the Parquet reader’s prefetch, which is used for remote
files but not for files on local disk, so a local DATA_PATH caches only
footers. A deployment reading s3:// takes the remote path.
That cache is memory, and it dies with the process — see below if you need warmth to survive a restart.
DuckLake data files are immutable once written, so a process serving repeat queries wants three DuckDB settings that are off by default:
These are global, so moraine will not set them from an ATTACH — that would reach into every other database in the process. Set them in the session that attaches the lake.
Keeping S3 data files cached across processes with cache_httpfs
Section titled “Keeping S3 data files cached across processes with cache_httpfs”The built-in cache above serves repeat reads within a live process. If
your processes are short-lived — a serverless handler, a redeploy every
few minutes — every one of them starts cold, and a cache on disk is what
survives. The cache_httpfs community extension replaces the s3://
filesystem, caching below the reader and outliving the instance:
Budget its memory separately from memory_limit. This cache is not
DuckDB’s and does not answer to DuckDB’s budget, and even in on_disk
mode it keeps a read-through memory cache. Two knobs bound it, and both
multiply by the block size:
So a host running this alongside moraine has three memory consumers to
size, not one: DuckDB’s memory_limit, moraine’s META_CACHE_MEMORY,
and cache_httpfs’s block count times its block size. A fourth sits outside
every budget: each attached catalog holds its own decoded view, bounded by
the catalog’s size rather than by a cap. The core reports it as
projection_bytes on a catalog handle. Only the first two
are visible to DuckDB. Set the block counts before the first read —
they take effect once.
Two smaller notes. cache_httpfs_max_in_mem_cache_block_count must be
set before any filesystem access to take effect. And DuckLake never
rewrites a data file, so the default
cache_httpfs_enable_cache_validation = false is safe here — the file
under a cached block cannot have changed.