---
title: Replicas and serving
description: What makes a replica disposable rather than authoritative. Sync-then-query freshness, cache outcomes, eviction under load, and ART indexing for point lookups.
---

A replica is one DuckDB file per board, attached into a server node's shared DuckDB instance on demand. It is a cache, never truth: the file can be deleted at any moment and rebuilt from the lake, which is what lets any server node answer for any board and what makes a node's local disk soft state rather than a source of correctness.

## One file per board

DuckDB allows one writer per database file. Only a replica's sync routine writes to its file, so concurrent application writes do not compete for that writer.

Using one file per board also provides:

- **The natural attach/detach unit.** DuckDB lets you `ATTACH` and `DETACH` files at runtime, so a board's cache lifetime is exactly its attach lifetime.
- **The natural blast-radius unit.** A corrupt or wedged replica is one file, discoverable and discardable without touching any other board's state.
- **Board-scoped schema evolution.** Two boards can each define a column named `status` with a different type with no risk of collision, because they are different tables in different files, not rows sharing one physical table with a `board_id` column.

## Sync-then-query

A read never trusts the replica file as-is. Before the query runs, the replica is brought up to the lake's (or WAL's) current head, and only then does the query execute against it:

```ts title="core/sync.ts"
export function isCurrent(replica: LocalReplica, head: HeadCache): boolean {
  return head.current() <= replica.watermarkNow();
}
```

When the replica is already current (the common case), this check is two in-memory numbers and nothing else: no catalog round trip, no DuckDB query. When it is not, `sync` (against the lake's change feed, under write path `"lake"`) or `syncFromWal` (against the WAL, under the default write path `"wal"`) fetches what changed and applies it, returning a `SyncResult`. The shape comes from `lakefront/contract`, re-exported through `core/types.ts`:

```ts title="core/types.ts"
interface SyncResult {
  from: number;
  to: number;
  changesApplied: number;
  rowsInserted: number;
  rowsDeleted: number;
  columnsAdded: string[];
  skipped: boolean;
}
```

`from` and `to` are the watermark range the sync covered; `skipped` is true when nothing needed to happen.

`rowsInserted`/`rowsDeleted` and `columnsAdded` describe what `apply()` actually did to the local file: inserts and deletes rather than updates, because the apply step reduces every change to delete-then-insert per touched id, which is what makes replay idempotent regardless of how many times the same range is applied.

## Cache outcomes

Alongside `SyncResult`, every query outcome also reports a `CacheOutcome` describing what it cost to get the replica file itself onto this node, independent of how stale it then was. It is the outcome a query's latency should be measured against rather than the query cost alone.

| Outcome    | What happened                                                                                                | Cost                                                                                                      |
| ---------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `hit`      | The file was already attached                                                                                | No I/O beyond the sync check                                                                              |
| `reattach` | The file was on disk but not currently attached to this instance                                             | Cheaper than a rebuild since the data is already there, but still a DuckDB `ATTACH` and an identity check |
| `rebuild`  | The file didn't exist, was stale from a different lake incarnation, or fell behind the WAL's retention floor | Materialized fresh from the lake (the most expensive outcome)                                             |

## Cache capacity and eviction

The replica cache holds an LRU of attached files, bounded by `cacheCapacity`. Attaching a board over capacity evicts the least-recently-used replica that has no work in flight.

Detaching a replica during an active query fails DuckDB's detach checkpoint. So eviction marks a board busy on acquire and waits for that count to drain (polling outside the catalog lock so it never stalls other boards' attaches) before it will detach.

On timeout it gives up for that pass rather than force the detach; the file stays attached and the next trigger tries again. And a failed detach never deletes the file: bookkeeping restores the replica as still-attached and the underlying file is left exactly as it was, since DuckDB may still be mid-write to it.

Connections to attached replicas come from a shared connection pool (`poolSize`, default 24, one number for the whole node rather than per board) rather than one connection per board, because a DuckDB connection can hold only one transaction at a time and two boards syncing concurrently must never share one.

## ART indexes

DuckLake tables carry no indexes: the lake is built for scans, not point lookups. A replica is a plain DuckDB file, so it can carry ART indexes, and `indexColumns` (default `["id"]`) is which columns do. Point lookups are the OLTP read shape, and an unindexed replica answers one by scanning; see [the performance numbers](/concepts/performance) for what that costs against an indexed lookup.

Every configured column also has a cost on the write side, since sync apply has to maintain it, so the option is meant to be spent on columns actually hit with point predicates rather than applied broadly by default.

DuckDB's ART index never reclaims space as rows are deleted and reinserted; there is no vacuum for it. So the engine tracks churn (rows deleted plus inserted since the index was last built) and, once churn crosses both a floor and a quarter of the table's row count, drops and recreates its own indexes as a compaction step (the same shape as compaction in any LSM-based store).

This is routine maintenance: left unindexed, the bloat is silent and cumulative.

A schema widening interacts with this directly. DuckDB refuses to `ALTER` a column on a table that an index depends on, so when a synced column needs to be retyped (the lake having widened it since this replica was built), the engine drops its own indexes for the duration of that one transaction, performs the retype, and re-creates the indexes once it commits.

That is why a sync immediately after a schema widening can pause noticeably longer than an ordinary sync: it is paying for an index rebuild inline with the retype rather than hanging.
