Write-behind mode (optional)#
By default the API is live: every insert, update, upsert and delete runs against the database before the answer is
sent, so the result is exact (generated ids, affected rows) and the data is current the moment the call returns.
Write-behind is an optional mode for servers that produce many small writes. The API checks each write, records it in a journal, answers "queued" at once, and applies the queued writes to the database later, in order, in batches. You trade immediacy for fewer database round trips and for resilience against short database outages.
Nothing changes unless the server owner turns it on, and the owner decides which namespaces and operations are affected. Schema changes and reads are never delayed.
When to use it, and when not#
Good fit:
- Frequent small updates whose exact result nobody waits for (stats, last-seen, counters, preferences, logs).
- The database is remote or slow and a game tick must not wait for it.
- You want writes to survive a short database outage without error handling in every caller.
Poor fit:
- You need the generated id or the affected row count of a write. A queued write cannot return them.
- A later step must see the data immediately (use
FlushBeforeRead, or send that one write with"durability":"immediate"). - Money-like data where a rejected write must be reported to the player. Rejections happen at flush time (see below).
Turning it on#
{
"Ldb": {
"WriteBehind": {
"Enabled": true,
"Namespaces": [ "mygame" ],
"Operations": [ "insert", "update", "upsert", "delete" ],
"FlushIntervalSeconds": 30,
"FlushAt": [ "04:00" ],
"TimeZone": "Europe/Berlin",
"FlushThreshold": 1000,
"JournalPath": "/data/write-behind.journal"
}
}
}
Environment variables work as everywhere: LDB_Ldb__WriteBehind__Enabled=true, LDB_Ldb__WriteBehind__FlushAt__0=04:00, …
| Setting | Default | Meaning |
|---|---|---|
Enabled |
false |
Master switch. Off means everything is live. |
Namespaces |
[] |
Namespaces whose writes are queued. Empty means all. |
Operations |
all four | Which of insert, update, upsert, delete are queued. |
FlushIntervalSeconds |
30 |
Flush every N seconds while writes are waiting. 0 disables interval flushing. |
FlushAt |
[] |
Flush at these times of day ("HH:mm", 24 h), for example ["04:00","16:00"]. |
TimeZone |
UTC |
Time zone id for FlushAt (IANA such as Europe/Berlin, or a Windows id). |
FlushThreshold |
1000 |
Flush early when this many writes are waiting. 0 disables. |
MaxQueuedOperations |
50000 |
Hard limit. A full buffer answers 503 write_buffer_full. |
MaxQueuedBytes |
67108864 |
Hard limit on the size of queued payloads. |
JournalPath |
empty | File that keeps queued writes across restarts and crashes. Empty keeps them in memory only. |
JournalFsync |
true |
Force every entry to disk before it is acknowledged. |
OnError |
Skip |
Skip: a write the database refuses for good goes to the dead-letter file. Stop: the flush stops at it. |
MaxRetries |
5 |
Retries with back-off for temporary database errors before a flush pauses and tries again later. |
FlushBeforeRead |
false |
Apply a namespace's queued writes before a find or count on it. |
ShutdownFlushSeconds |
30 |
How long a graceful shutdown waits for the final flush. |
An invalid FlushAt or TimeZone stops the service at start-up with a clear message.
What the caller sees#
A queued write is answered with a signed 200:
{ "queued": true, "id": "9f1c0c9e5b4a4d3f8f3b…", "pending": 12 }
idis a receipt for logs;pendingis the queue length after this write.- There is no
idslist for an insert and noaffectedcount for update, upsert and delete. Tables withautoIncrementkeys give no ids back, so use client-generated keys (uuid, a natural key) when you need to refer to the row afterwards. - The write has been validated against the stored schema before it is accepted: unknown table or column, missing required
column, wrong JSON kind, a too long string, an update without a filter, a primary key change and similar mistakes are refused
at once with
400/404, exactly like in live mode. Only what the database itself decides (a unique value that already exists, for example) can still fail later. "durability":"immediate"in the payload sends one write live, even in a buffered namespace. Older queued writes of that namespace are applied first, so the order is kept.- The permission
AllowUnfilteredWritesis checked when the write is accepted, as in live mode.
When queued writes are applied#
A flush applies waiting writes in the order they were accepted. It runs
- every
FlushIntervalSecondswhile something is waiting, - at each time listed in
FlushAt(inTimeZone), - when
FlushThresholdwrites are waiting, - when a client calls
POST /v1/data/flushwith{"namespace":"mygame"}(needs the operationflush; the answer is{"flushed":n,"deadLettered":m,"remaining":k,"halted":false,"enabled":true}), - before a schema change of that namespace, because the writes were checked against the old structure,
- before a read of that namespace if
FlushBeforeReadis on, - before an immediate write of that namespace,
- when the service shuts down gracefully (up to
ShutdownFlushSeconds).
Consecutive inserts into the same table are merged into one database call on SQL providers (up to Limits:MaxInsertRows
rows). MongoDB keeps separate queued requests separate: ordered bulk inserts may partially succeed, so splitting and
replaying a failed merged batch could repeat already-applied writes. An individual multi-row MongoDB request can still
partially succeed on a database error.
For fixed times only, set FlushIntervalSeconds: 0 and FlushThreshold: 0. Otherwise those triggers can apply writes
before the configured clock time. Namespace requests are serialized while buffering is enabled so concurrent writes
cannot enter between a schema change's drain and the actual schema change.
If a required flush halts, schema changes, immediate writes and FlushBeforeRead reads return 503 write_buffer_halted
instead of overtaking pending writes. Fix the failing write/database and retry the flush. The explicit flush endpoint
itself returns its signed report; inspect halted and remaining.
Reads (find, count) always see only what has been applied. That is the point of the mode; turn on FlushBeforeRead if a
namespace needs read-your-writes.
When things go wrong#
| Situation | What happens |
|---|---|
| The database is down or times out | The flush retries with back-off (MaxRetries), then pauses. Nothing is lost; the next trigger tries again. health reports halted: true while paused. |
| The database refuses a write for good (constraint, bad value) | With OnError: Skip the write is appended to <JournalPath>.dead with its error code and the flush continues. With Stop it stays at the head of the queue and the flush halts until you fix or remove it. |
| A merged insert is refused | It is split and applied write by write, so only the offending write is rejected. |
| The buffer is full | New writes get 503 write_buffer_full (or send them with durability: immediate). |
| The journal cannot be written (disk full) | The write is not accepted: 503 write_buffer_unavailable. |
| The process crashes or is killed | With a journal: queued writes are loaded at the next start and applied. A write that the database had confirmed but the journal not yet marked can be applied twice, so make writes idempotent where it matters (upsert, set instead of inc, unique keys). Without a journal: queued writes are lost. |
| Graceful shutdown | A final flush runs. Anything left stays in the journal; without a journal a warning names how many writes were lost. |
The delivery guarantee is at least once.
Operating it#
- Journal and dead-letter files contain the payloads (row values). Protect them like the database: restrictive file
permissions, a data volume, backups if the data matters. In Docker mount a volume at
/data. - Watch
ldb_writebehind_pending(gauge),ldb_writebehind_applied_totalandldb_writebehind_dead_lettered_total(meterLdb.Api), thewrite-behind flush …log lines, and the authenticatedhealthresult:"writeBuffer":{"enabled":true,"pending":12,"oldestAgeSeconds":3,"halted":false}. - Alert on a growing
pending, onhalted: trueand on any dead-lettered write. - Dead letters are JSON lines
{"id","op","ns","p","code","message","t"}. Fix the cause, then re-send the payloadpwith a normal write. - Several API instances each keep their own queue and journal. Ordering is per instance.
- Maintenance: a flush before you stop the service is automatic. For a planned database migration call
flushfor each namespace first and wait forremaining: 0.
Using it from the lt_database bundle#
Nothing changes for callers. Db.Insert/Update/Upsert/Delete return a result whose Json is the queued receipt when the server
owner buffers that namespace: check it with Db.IsQueued(result). Db.Flush(ns, done) forces a flush, and every write helper
accepts immediate: true. See the lt_database documentation.