docs: add realtime engine (RTE) getting-started guide (#4548)

Adds a user-facing realtime engine (RTE) page at
`docs/web/tables/realtime-engine.mdx`, listed under **Management** after
Branches. It is adapted from the internal WAL field guide
(lancedb/sophon#6213), with the deployment and operations material left
out.

The page covers:
- what RTE is and how it works (memtable → SSTables → compaction; reads
merge the base table with the fresh tier)
- getting started: `set_unenforced_primary_key`, indexes, `enable_rte` /
`ALTER TABLE ... SET RTE`, `merge_insert` upserts and queries, partial
updates, deletes, and `checkpoint_rte` / `disable_rte`
- performance: write throughput and shard scaling, and read latency with
and without RTE
- a note on operations not yet supported (time travel, branching,
`count_rows`, row IDs, `TABLESAMPLE`)

Also aligns related docs with current RTE naming and behavior:
- `sql/ddl/table.mdx`: `SET MEMWAL` / `UNSET MEMWAL` → `SET RTE` /
`UNSET RTE`
- `maintenance/background-jobs.mdx`: job names are `memwal_compaction`
and `memwal_catchup_<type>`
- `feature-engineering/materialized-views.mdx`: MemWAL → realtime engine
- Python `AsyncTable.enable_rte` /
`RTEWriteSpec.with_maintained_indexes` docstrings: by default, indexes
created after enabling RTE are maintained too

The examples are written directly in the page rather than generated from
`docs/web-tests`, because RTE needs an Enterprise server to run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: XY Zhan <zhaner08@hotmail.com>
This commit is contained in:
Dan Rammer
2026-10-20 21:40:20 -05:00
committed by GitHub
co-authored by Claude Opus 5.5 XY Zhan
parent 9e54c8cf27
commit 951ae51ef6
7 changed files with 251 additions and 19 deletions
+1
View File
@@ -327,6 +327,7 @@
"management/namespace",
"management/table",
"tables/branching",
"tables/realtime-engine",
"management/materialized-view",
"management/view"
]
@@ -52,7 +52,7 @@ In Python SDK 0.40.0, create the source with
above does. Stable row IDs let the view track source rows across updates and
compaction. Set this option when creating the table; it cannot be added later.
A source with a MemWAL write spec or retained, uncompacted write-ahead-log
A source with the [realtime engine](/tables/realtime-engine) enabled or retained, uncompacted write-ahead-log
rows is not accepted by the local materialized-view implementation.
## Refresh behavior {#incremental}
+3 -3
View File
@@ -27,13 +27,13 @@ also start cache-warming jobs through the admin API.
## Write-ahead log maintenance {#wal}
For a table with a [write-ahead log](/sql/ddl/table#properties) enabled, two jobs keep the
For a table with the [realtime engine](/tables/realtime-engine) enabled, two jobs keep the
log from growing without bound:
| Job type | What it does |
| --- | --- |
| `sstable_compaction` | Folds a table shard's SSTables into the base table by primary key. Distinct from `compact`, which merges base-table fragments. The two are unrelated. |
| `wal_index_catchup` | Extends a base-table index over the rows `sstable_compaction` just folded in, and records how far the index has caught up. Nothing else advances that record. |
| `memwal_compaction` | Folds a table shard's SSTables into the base table by primary key. Distinct from `compact`, which merges base-table fragments. The two are unrelated. |
| `memwal_catchup_<type>` | Extends a base-table index over the rows `memwal_compaction` just folded in, and records how far the index has caught up. Nothing else advances that record. The suffix names the index type, for example `memwal_catchup_btree`, `memwal_catchup_fts`, or `memwal_catchup_ivfpq`. |
## Index maintenance {#index}
+8 -6
View File
@@ -102,18 +102,20 @@ duplicates impossible. It tells the planner what to assume. The column must be
declared `NOT NULL`.
```sql
ALTER TABLE t SET MEMWAL
ALTER TABLE t SET MEMWAL WITH (sharding = 'bucket', column = 'id', buckets = 4)
ALTER TABLE t SET MEMWAL WITH (maintained_indexes = '')
ALTER TABLE t UNSET MEMWAL
ALTER TABLE t SET RTE
ALTER TABLE t SET RTE WITH (sharding = 'bucket', column = 'id', buckets = 4)
ALTER TABLE t SET RTE WITH (maintained_indexes = '')
ALTER TABLE t UNSET RTE
```
A write-ahead log makes small frequent writes cheap by batching them out of the
The [realtime engine](/tables/realtime-engine) puts a write-ahead log in front
of the table, making small frequent writes cheap by batching them out of the
commit path. `sharding` and `buckets` spread the log so concurrent writers do
not contend on one place; `bucket` sharding names its `column`, which must be
the table's unenforced primary key, and inserts into the table then need a
scalar index on that key. `maintained_indexes` chooses which indexes are kept
current against WAL rows. A table with a computed column cannot take a
current against WAL rows; omitted, every supported index is kept current,
including ones created later. A table with a computed column cannot take a
write-ahead log. See
[Background jobs](/maintenance/background-jobs#wal) for how the log gets
folded back into the table.
+229
View File
@@ -0,0 +1,229 @@
---
title: "Realtime engine (RTE)"
sidebarTitle: "Realtime Engine"
description: "Absorb small, frequent writes cheaply and make them queryable within milliseconds."
icon: "bolt"
keywords: ["rte", "realtime engine", "wal", "memwal", "lsm", "write-ahead log", "streaming writes", "upsert"]
---
<Badge color="red">Enterprise</Badge>
## What is the realtime engine? {#what-is-rte}
Every write to a LanceDB table is normally a commit to the underlying Lance
dataset. That is ideal for bulk loads, but a stream of small upserts becomes a
stream of small commits, and each write is only visible once its commit lands.
The realtime engine (RTE) puts an LSM-style write tier in front of the table.
It turns many small writes into a few large commits, and makes each write
**searchable within milliseconds** of being acknowledged. It is enabled per
table, and nothing about how you query that table changes.
## How it works {#how-it-works}
- **Writes.** A `merge_insert` or `delete` lands in an in-memory **memtable**
and is acknowledged once it is durable in the write-ahead log on object
storage.
- **Flush and compaction.** Memtables freeze into **SSTables**, which background
jobs compact into the base table, catching its indexes up as they go. See
[Background jobs](/maintenance/background-jobs#wal).
- **Reads.** Every query runs against the base table and the fresh tier
(memtables and SSTables) in parallel. Fresh rows supersede base rows with the
same primary key, and the two are merged into one result.
- **Indexes stay fresh.** Vector, full-text, BTree, bitmap, and most other
scalar indexes are maintained on the memtable, so new rows are searchable
through the index as soon as they are written.
## Getting started {#getting-started}
### 1. Create a table with a primary key {#primary-key}
RTE deduplicates writes by primary key. Create the table with a non-nullable
key column, then declare it with `set_unenforced_primary_key`. The key is a
single `int32`, `int64`, string, or binary column, and cannot be changed once
set.
```python
from datetime import timedelta
import lancedb
import numpy as np
import pyarrow as pa
from lancedb import RTEWriteSpec
from lancedb.index import FTS, BTree, IvfPq
db = lancedb.connect("db://my_db", api_key="...", host_override="https://...")
n, dim = 1024, 128
schema = pa.schema([
pa.field("id", pa.int64(), nullable=False),
pa.field("vector", pa.list_(pa.float32(), dim)),
pa.field("text", pa.utf8()),
])
initial_data = pa.table({
"id": pa.array(range(n), pa.int64()),
"vector": pa.FixedSizeListArray.from_arrays(
pa.array(np.random.rand(n * dim).astype("float32")), dim),
"text": pa.array([f"doc {i} alpha bravo" for i in range(n)]),
}, schema=schema)
table = db.create_table("docs", initial_data, schema=schema)
table.set_unenforced_primary_key("id")
```
Or in SQL:
```sql
ALTER TABLE docs SET UNENFORCED PRIMARY KEY (id)
```
### 2. Create indexes {#indexes}
By default, RTE maintains every supported index on the table, including
indexes created after it is enabled. A scalar index on the primary key is
required for partial updates and deletes.
```python
table.create_index("vector", config=IvfPq())
table.create_index("text", config=FTS())
table.create_index("id", config=BTree())
table.wait_for_index(["vector_idx", "text_idx", "id_idx"], timeout=timedelta(minutes=5))
```
### 3. Enable RTE {#enable}
```python
table.enable_rte(RTEWriteSpec.unsharded())
```
Or in SQL:
```sql
ALTER TABLE docs SET RTE
```
### 4. Write and query {#write-and-query}
<Warning>
On an RTE table, an upsert through `merge_insert` is the only way to write
rows. Use the form below exactly: `when_matched_update_all()` with no filter,
plus `when_not_matched_insert_all()`. Other `merge_insert` forms, such as
conditional updates or deleting rows missing from the source, are rejected.
</Warning>
Writes go through `merge_insert` on the primary key, and are visible to queries
immediately:
```python
new_rows = pa.table({
"id": pa.array([2000, 2001], pa.int64()),
"vector": pa.FixedSizeListArray.from_arrays(
pa.array(np.random.rand(2 * dim).astype("float32")), dim),
"text": pa.array(["fresh charlie delta", "fresh echo foxtrot"]),
}, schema=schema)
query_vector = np.random.rand(dim).astype("float32")
table.merge_insert("id") \
.when_matched_update_all() \
.when_not_matched_insert_all() \
.execute(new_rows)
table.search(query_vector).limit(10).to_list()
table.search("charlie").limit(10).to_list()
```
**Partial updates.** Send the primary key plus only the columns you want to
change; omitted columns keep their current values. This requires a scalar
index on the primary key.
```python
patch = pa.table(
{"id": [0, 1], "text": ["doc 0 revised", "doc 1 revised"]},
schema=pa.schema([schema.field("id"), schema.field("text")]),
)
table.merge_insert("id").when_matched_update_all().when_not_matched_insert_all().execute(patch)
```
**Deletes.** The predicate must bind the primary key to literal values. Ranges,
`OR`, and filters on other columns are rejected. Deletes require a scalar index
on the primary key.
```python
table.delete("id IN (2000, 2001)")
```
### Disabling RTE {#disable}
Stop writes and wait for in-flight writes to finish, then flush pending writes
into the base table and disable. `checkpoint_rte()` only drains writes that
existed when it started, so anything written during the checkpoint can be left
behind. `disable_rte()` is rejected while the write-ahead log still holds rows,
so run `checkpoint_rte()` first.
```python
table.checkpoint_rte()
table.disable_rte()
```
## Performance {#performance}
### Writes {#write-performance}
RTE acknowledges a write as soon as it is durable in the write-ahead log, so
ingest is no longer limited by how fast the table can commit.
**Compared with writing directly to the table.** On tables ranging from
hundreds of thousands to tens of millions of rows, RTE delivered 4–12× the
write throughput and 24–52× lower p99 write latency, depending on the mix of
inserts, updates, and deletes.
**Scaling with shards.** An RTE table can be split into
[shards](/sql/ddl/table#properties) that write independently, and a single
table's ingest grows with its shard count. In our testing on AWS, with every
index on the table (vector, full-text, and BTree) kept current, a single table
reached these peak rates:
| Shards | Peak ingest |
| --: | --: |
| 1 | 90 MB/s |
| 2 | 170 MB/s |
| 4 | 340 MB/s |
| 8 | 625 MB/s |
| 16 | 690 MB/s |
| 32 | 885 MB/s |
Adding shards scales a table's ingest nearly linearly.
### Reads {#read-performance}
Reads on an RTE table stay close to those on a fully compacted table, even
with the fresh tier at its fullest before compaction.
The table below compares a 5M-row table with a 128-dimension IVF-PQ index plus
BTree and FTS indexes, with and without RTE. The RTE table holds one
unflushed memtable and three SSTables, each from a 100k-row write wave, half of
which are updates to existing rows. Latencies are p50 in milliseconds.
| Query | Without RTE | With RTE |
| --- | --: | --: |
| Primary-key lookup | 62 | 63 |
| Vector search | 68 | 71 |
| Vector search with prefilter | 90 | 91 |
| Full-text term | 63 | 64 |
| Full-text phrase | 68 | 82 |
| Hybrid search (RRF) | 68 | 83 |
| SQL point lookup | 189 | 187 |
Most queries are within a few milliseconds of the base table. Hybrid and phrase
queries add roughly 15 ms to merge the two tiers.
<Note>
Support for all table operations on RTE tables is coming soon. Time travel,
branching, `count_rows`, row IDs (`with_row_id`), and `TABLESAMPLE` are not yet
available.
</Note>
## Next steps {#next-steps}
- [Merge insert](/ingestion/merge-insert)
- [Background jobs](/maintenance/background-jobs#wal)
- [`ALTER TABLE ... SET RTE`](/sql/ddl/table#properties)
+3 -3
View File
@@ -850,9 +850,9 @@ class RTEWriteSpec:
@staticmethod
def unsharded() -> "RTEWriteSpec": ...
def with_maintained_indexes(self, indexes: Optional[List[str]]) -> "RTEWriteSpec":
"""Set which indexes the RTE keeps up to date. None resolves every
index on the table at install, failing if one cannot be maintained;
a list is verbatim, empty means none."""
"""Set which indexes the RTE keeps up to date. None is every index on
the table, now and later, skipping any it cannot maintain; a list is
verbatim, empty means none."""
...
def with_writer_config_defaults(self, defaults: Dict[str, str]) -> "RTEWriteSpec":
"""Return a copy of this spec recording the given default
+6 -6
View File
@@ -5564,12 +5564,12 @@ class AsyncTable:
via [`set_unenforced_primary_key`]; bucket sharding additionally
requires it to be the single column being bucketed.
By default the RTE maintains every index on the table, resolved
here — a snapshot, so an index created afterwards needs the spec unset
and set again. This fails if one cannot be maintained; name the set
with ``with_maintained_indexes`` to install anyway. That pins an exact
set (a still-building index is rejected, not omitted); ``[]`` maintains
none.
By default the RTE maintains every index on the table, re-read as the
table changes, so an index created later is maintained too; one of a
kind the RTE cannot mirror is skipped. Name the set with
``with_maintained_indexes`` to pin it instead: each name must already
exist and be maintainable (a still-building index is rejected, not
omitted); ``[]`` maintains none.
Parameters
----------