backtest-kit’s write durability contract is strict: afterDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/backtest-kit/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(...) must see the just-written value. The default file-based persistence satisfies this trivially — fs.writeFile followed by fs.readFile on the same node is effectively serialized. A naïve SQL implementation that does a SELECT to check existence, then either INSERT or UPDATE, does not satisfy this contract under concurrent access or on a replica cluster with asynchronous streaming replication.
The race condition
Consider two parallel backtest workers processing the same(symbol, strategyName, exchangeName) context. Both call findByContext at the same time, both see no existing row, and both proceed to INSERT. The second insert crashes with a unique-constraint violation. The framework may retry indefinitely, silently corrupt state, or stall the backtest engine.
Even without concurrency the problem exists on a read-replica cluster. The write goes to the primary. The follow-up SELECT can be routed to an async replica that has not yet received the commit. The read returns a stale value — or null — even though the write already committed. Code that passes every test on a single Postgres node silently breaks in production, because a lone node serializes everything through one process and hides the gap.
The atomic solution
Everyupsert in this project is a single atomic round-trip — one INSERT … ON CONFLICT … DO UPDATE … RETURNING * statement that does not require a preceding SELECT. The TypeORM query-builder maps cleanly onto this SQL pattern:
(symbol, strategyName, exchangeName) — matches the unique index declared on the table. When two concurrent inserts race, PostgreSQL serializes them at the storage level: the loser takes the DO UPDATE branch without throwing, and both callers receive a valid row from RETURNING.
Four key properties
Conflict target == unique index shape
Every table declares a compound unique index whose columns are exactly the context-key fields (e.g.
signal_items_uq on (symbol, strategyName, exchangeName)). PostgreSQL acquires a row-level lock on the conflicting key at the storage engine level before deciding which branch to take. The losing concurrent insert updates the row rather than throwing a constraint violation — the unique-violation never leaks to the application layer.DO UPDATE SET payload = EXCLUDED.payload — a real update
The conflict branch is a genuine write:
payload is replaced with the value from the incoming EXCLUDED pseudo-row. This means subsequent calls to writeSignalData with a new payload are real updates, not no-ops. The exception is CandleDbService, where OHLCV data is immutable once written (see the Candle insert-only pattern below).RETURNING * — the written row, in the same statement
RETURNING * yields the just-written or just-updated row as part of the same statement result. Its id (a gen_random_uuid() UUID) is used to seed the Redis cache immediately, in the same critical section as the write. The Redis cache is never seeded from a follow-up SELECT, which could be routed to a lagging replica.Candle insert-only pattern
Candles are immutable: once an OHLCV row is stored for a given(symbol, interval, timestamp), it must never be overwritten. However, ON CONFLICT DO NOTHING cannot return the conflicting row via RETURNING without a follow-up SELECT — which may hit a lagging replica and break the read-after-write guarantee.
The solution is a no-op DO UPDATE that rewrites the natural key column to its own EXCLUDED value. The OHLCV columns are never touched, but the row is always returned by RETURNING whether it was freshly inserted or already existed:
symbol = EXCLUDED.symbol) satisfies PostgreSQL’s requirement that DO UPDATE sets at least one column, while ensuring OHLCV values are never mutated after their first write.
Soft-delete atomic pattern
For Measure, Interval, and Memory, rows are never physically deleted. Instead,softRemove flips a removed: boolean flag and sets the nested payload.removed field in a single server-side UPDATE using jsonb_set. There is no SELECT-then-save window where a concurrent upsert could be lost:
jsonb_set) inside a single statement, there is no read-modify-write window where a concurrent upsert could be lost. listKeys and listEntries filter on removed = false to skip tombstones.
The single-node atomicity illusion
RETURNING vs follow-up SELECT
The Redis cache is always seeded from the row returned by
RETURNING * in the same write statement — never from a follow-up SELECT. This matters on a replica cluster: a subsequent SELECT may be routed to a replica that has not yet applied the write. By seeding Redis from the primary’s RETURNING row, the next findByContext call gets a cache hit that resolves via a Postgres primary-key lookup, bypassing replica routing entirely.