Transaction Spill
Wallaby uses pgoutput protocol v2, so a transaction larger than the server's logical_decoding_work_mem (default 64 MB) is streamed before its commit. The spill buffers those streamed changes out of process memory until the commit arrives, so a single huge transaction can't exhaust the worker's heap. Small transactions, the overwhelming majority, never touch it.
Choosing a backend
csharp
cdc.SpillToDatabase(); // default: wallaby.stream_buffer UNLOGGED table on the source DB
cdc.SpillToDisk("/var/spill"); // local temp files (path optional)
cdc.UseTransactionSpill(ctx => new S3Spill(ctx.SlotName)); // your own backendSpillToDatabase()(default) is disk-free and zero-config (it works wherever Wallaby connects), at the cost of I/O amplification on the source database during large transactions.SpillToDisk(path?)writes append-only files under the given path (default%TEMP%/wallaby/<slot>). It needs a writable path, so it isn't suitable for read-only environments.UseTransactionSpill(...)plugs in a custom backend. The factory runs once per leader session and should return a fresh instance; Wallaby disposes it when the session ends.
Interface
csharp
public interface ITransactionSpill : IAsyncDisposable
{
ValueTask AppendAsync(uint xid, uint subxid, RawChange change, CancellationToken ct);
IAsyncEnumerable<RawChange> ReadAsync(uint xid, CancellationToken ct);
ValueTask DiscardAsync(uint xid, CancellationToken ct);
ValueTask DiscardSubtransactionAsync(uint xid, uint subxid, CancellationToken ct);
ValueTask ClearAsync(CancellationToken ct);
}
public readonly record struct SpillContext(
NpgsqlDataSource DataSource, // pooled connections to the source database
string SlotName, // namespace your buffered data by slot
IServiceProvider Services); // resolve your backend's own dependenciesChanges are appended per transaction (xid) as they stream and read back in append order at the commit. An implementation owns its own serialization of RawChange; the abstraction deals purely in changes.
Implementation Guidance
- Savepoint truncation.
DiscardSubtransactionAsync(xid, subxid)handles a rolled-back savepoint: remove the changes appended withsubxidand every change appended after its first one (a later change can only belong to the aborted subtransaction or one nested inside it, which aborts with it). It must be a no-op whensubxidnever appended anything. Changes appended afterwards must survive and be returned by a laterReadAsync. - All-or-nothing reads. If the backing store no longer holds everything appended for an
xid,ReadAsyncshould fail rather than yield a partial buffer; a partial read that succeeded would be delivered and acknowledged as if complete. - No durability across restarts. A streamed transaction that never commits (a crash) is re-streamed from the slot, so the spill need not survive a restart;
ClearAsyncdrops any leftovers on startup.
WARNING
Do not implement an in-memory spill as you may exhaust system resources.