backtest-kit ships with a hard write durability contract: afterDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/theonetrade/backtest-kit-redis-postgres-pgpool-docker/llms.txt
Use this file to discover all available pages before exploring further.
writeXData(...) returns, the very next readXData(...) call for the same context key must see the just-written value. The default file-based persistence satisfies this trivially — fs.writeFile + fs.readFile on a local filesystem are serialized by the OS. Replacing that with a PostgreSQL database introduces subtleties that, if ignored, silently corrupt strategy state in production.
Why Naïve SQL Fails
The obvious SQL translation of “write if not exists, update if exists” is a two-step read-then-write:null), both attempt INSERT, and the second one crashes with a unique-constraint violation. The backtest-kit framework catches that error, re-fetches from the exchange, and retries the write — but it may loop forever or silently record a stale value, depending on the retry logic. Even without concurrency, a follow-up SELECT after a committed INSERT can be routed to a read replica that has not yet received the commit, returning a stale row.
The Solution: One Atomic Round-Trip
Everyupsert in this project executes a single INSERT … ON CONFLICT … DO UPDATE … RETURNING * statement. No read, no separate update — one statement, one write transaction, one primary.
Conflict target = unique index
Every table has a unique compound index whose columns exactly match the adapter’s context key fields (e.g.
signal_items_uq on (symbol, strategyName, exchangeName)). PostgreSQL serializes concurrent inserts on that key at the storage-engine level — the loser of the race takes the DO UPDATE branch instead of throwing, so no unique-constraint violation ever reaches the application.RETURNING * seeds Redis immediately
The just-written row is returned by the same statement. Its
id is fed to the Redis cache synchronously, so the next findByContext call is O(1) — and the cache is seeded from the primary’s committed data, never from a follow-up SELECT that could land on a lagging replica.DO UPDATE SET payload = EXCLUDED.payload
Subsequent writes to the same context key are real updates. The
EXCLUDED pseudo-table holds the values from the attempted insert, so payload = EXCLUDED.payload overwrites the stored value with the new one. No lost-update window exists.uuid PKs via gen_random_uuid()
TypeORM generates UUIDs server-side (
generated: "uuid") and createDate / updatedDate columns are applied automatically on insert. No application-side ID or timestamp bookkeeping is needed.Candle Immutability Exception
OHLCV candles are write-once data. Once a candle for a given(symbol, interval, timestamp) triplet exists, its price and volume columns must never be overwritten — market data is immutable. The CandleDbService therefore uses a no-op DO UPDATE that rewrites only the natural-key column to its own EXCLUDED value:
DO UPDATE SET symbol = EXCLUDED.symbol writes are effectively no-ops on the OHLCV data, but they still produce a row via RETURNING *. This is critical: ON CONFLICT DO NOTHING cannot RETURNING the conflicting row without a separate SELECT, and that separate SELECT might be routed to an async replica with replication lag — breaking the read-after-write invariant. The no-op update keeps everything in one primary-side statement.
The pattern
DO UPDATE SET symbol = EXCLUDED.symbol is a PostgreSQL idiom for “upsert that always returns the row but never changes data columns.” It satisfies RETURNING without touching OHLCV values.Soft-Delete Atomic Pattern
For adapters that support logical deletion — Measure (LLM response cache), Interval (once-per-interval markers), and Memory (per-signal memory store) — rows are never physically deleted. Instead, aremoved: boolean column is flipped and a jsonb_set call embeds the flag inside the payload document simultaneously. Both happen in a single server-side UPDATE:
jsonb_set) inside one UPDATE statement. There is no SELECT-then-save window where a concurrent upsert could overwrite the removed flag or lose the payload update. listKeys filters tombstones with removed = false so they are invisible to reads after deletion.
The Single-Node Atomicity Illusion
There is a subtle trap that only surfaces on a cluster. A lone PostgreSQL instance is one process: all concurrency is arbitrated internally by row locks and MVCC — effectively “atomicity through one global mutex”. On such a node, even a sloppy write followed by a separateSELECT appears correct, because that SELECT hits the very same process that just committed.
Add read replicas and the illusion breaks. Writes go to the primary, but reads are load-balanced onto asynchronous replicas that lag behind by milliseconds. A write + follow-up SELECT can be routed to a replica that has not yet received the commit, returning a stale value or even relation does not exist right after schema creation — silently violating the read-after-write contract. Code that passed every test on a single node corrupts state in production.
This is precisely why the two patterns above never issue a follow-up read: the written row comes back in the same statement via RETURNING, and the Redis cache is seeded from it. It is also why the dev environment (docker/pgpool) runs a real cluster with two replicas rather than a single Postgres container — so any accidental read-after-write dependency is caught in development, not in production.
Write committed on primary
INSERT … ON CONFLICT … DO UPDATE executes on the primary. The row is committed and returned by RETURNING * in the same round-trip.Redis seeded from primary result
cacheService.setXId(result) stores context-key → uuid in Redis immediately. No replica involved at any point in the write path.