Skip to content

Commit 9f31074

Browse files
committed
fix(storage): wrapper tx-handle forwarding + real-pool mutex bypass
Addresses Copilot review on PR #458. Five related fixes flagged after the real-pool dedicated-client refactor exposed gaps in the wrapper layer and a perf regression in the per-method mutex. PostgresTabularStorage — mutex bypass on real `pg.Pool`: The per-instance mutex was wrapping every public method, serializing reads and writes that previously fanned across separate pool clients in parallel. With the dedicated-client `withTransaction` path, the mutex is no longer needed for tx isolation on real pool — `pool.connect()` provides that. The mutex now short-circuits to a no-op when the handle exposes `connect()`, and only serializes on the single-connection path (PGlite / PGLitePool) where it is required to keep external traffic out of the open transaction. TelemetryTabularStorage.withTransaction — forward inner tx handle: Previously called `inner.withTransaction(() => fn(this))`, throwing away the inner's tx proxy and passing the outer wrapper to `fn`. With the new createTxView proxy, `fn(this).put(...)` would re-enter the inner's PUBLIC put through the mutex-acquiring path and deadlock on single-connection backends, or run on the wrong connection on real pool. Now constructs a fresh `TelemetryTabularStorage` wrapping the inner's `tx` proxy and passes that to `fn`, so writes inside the callback flow through the transaction-bound resources. ScopedTabularStorage.withTransaction — forward + defer events: Same forwarding fix as Telemetry. Additionally, since Scoped emits events on its own local emitter (not via the inner's `put` events), the inner's deferred-event flush does not cover Scoped's outer events. The tx wrapper's emitter is replaced with a per-tx buffer; entries are flushed to the parent's emitter only after `inner.withTransaction` resolves successfully (rollbacks discard via promise rejection). CachedTabularStorage.withTransaction — explicit override: Previously inherited `BaseTabularStorage`'s no-op default, silently losing rollback / atomicity for callers using the cached wrapper. Now forwards to the durable store's `withTransaction` so writes inside `fn` are atomic. The cache layer is bypassed for the transaction's duration; documented post-tx staleness is repopulated on the first read miss. ITabularStorage.withTransaction JSDoc — bring docs in step: Previous text claimed the API throws on a real `pg.Pool`, said the storage instance is not isolated from concurrent callers, and stated that `tx === this` for every backend — all out of date after the recent refactor. Updated to describe: real-pool path uses `pool.connect()`; concurrent calls are isolated (mutex on single-conn, dedicated client on real pool); the `tx` handle is a Proxy distinct from `this`, and callers MUST use it to avoid deadlock or wrong-connection writes. 767 storage tests passing locally.
1 parent cad033c commit 9f31074

5 files changed

Lines changed: 119 additions & 27 deletions

File tree

packages/knowledge-base/src/knowledge-base/ScopedTabularStorage.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,42 @@ export class ScopedTabularStorage<
227227
}
228228

229229
withTransaction<T>(fn: (tx: this) => Promise<T>): Promise<T> {
230-
return this.inner.withTransaction(() => fn(this));
230+
// Two responsibilities here:
231+
// 1. Forward the inner's `tx` proxy into `fn`, so writes from the
232+
// callback go through the transaction-bound resources rather than
233+
// re-entering the public mutex path on the parent (which would
234+
// deadlock on single-connection backends, or run on the wrong
235+
// connection on real `pg.Pool`).
236+
// 2. Defer events emitted by the scoped wrapper until the inner
237+
// transaction actually commits — Scoped emits on its own emitter
238+
// (separate from the inner's `put` events), so the inner's
239+
// deferred-event flush does not cover them.
240+
const deferred: Array<[TabularEventName, unknown[]]> = [];
241+
return this.inner
242+
.withTransaction((innerTx: AnyTabularStorage) => {
243+
const txWrapper = new ScopedTabularStorage<
244+
Schema,
245+
PrimaryKeyNames,
246+
Entity,
247+
PrimaryKey,
248+
InsertType
249+
>(innerTx, this.kbId);
250+
// Override the tx wrapper's emitter to buffer instead of fan out;
251+
// listeners on the original wrapper see events only after COMMIT.
252+
(txWrapper as unknown as { events: { emit: (n: string, ...a: unknown[]) => void } }).events =
253+
{
254+
emit: (name: string, ...args: unknown[]) => {
255+
deferred.push([name as TabularEventName, args]);
256+
},
257+
};
258+
return fn(txWrapper as unknown as this);
259+
})
260+
.then((result) => {
261+
for (const [name, args] of deferred) {
262+
(this.events.emit as (n: TabularEventName, ...a: unknown[]) => void)(name, ...args);
263+
}
264+
return result;
265+
});
231266
}
232267

233268
// Lifecycle — no-op for shared storage

packages/postgres/src/storage/PostgresTabularStorage.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -657,18 +657,26 @@ export class PostgresTabularStorage<
657657
}
658658

659659
/**
660-
* Per-instance promise-chain mutex. Every public read/write method awaits
661-
* the previous holder before running and yields the lock when it settles;
662-
* `withTransaction` holds it for the duration of the user's callback so
663-
* concurrent calls from outside `fn` queue behind the transaction instead
664-
* of slipping into it.
660+
* Per-instance promise-chain mutex. Only meaningful on single-connection
661+
* backends (PGlite / PGLitePool) — there `withTransaction` runs on the
662+
* shared session and we need to keep external callers from slipping into
663+
* the open transaction. On a real `pg.Pool` (anything exposing
664+
* `connect()`) the mutex would serialize independent reads/writes that
665+
* Postgres is happy to fan across separate pool clients, turning the
666+
* pool's main benefit into a per-instance bottleneck — so we short-circuit
667+
* to a no-op on that path. Real-pool isolation comes from
668+
* `withTransaction` dedicating its own client via `pool.connect()`.
665669
*
666670
* The Proxy returned by {@link createTxView} routes back to the private
667671
* `_*Internal` methods directly, so calls made *through* the `tx` handle
668672
* inside `fn` do not deadlock against the mutex held by `withTransaction`.
669673
*/
670674
private mutexChain: Promise<void> = Promise.resolve();
675+
private get serializeOps(): boolean {
676+
return typeof (this.db as unknown as { connect?: unknown }).connect !== "function";
677+
}
671678
private async mutex<T>(fn: () => Promise<T>): Promise<T> {
679+
if (!this.serializeOps) return fn();
672680
const prev = this.mutexChain;
673681
let release!: () => void;
674682
this.mutexChain = new Promise<void>((resolve) => {

packages/storage/src/tabular/CachedTabularStorage.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,31 @@ export class CachedTabularStorage<
328328
await this.cache.deleteSearch(criteria);
329329
}
330330

331+
/**
332+
* Runs `fn` inside the durable store's transaction. The cache layer is
333+
* intentionally bypassed for the transaction's duration — coordinating
334+
* two-phase commit between the durable store and an in-memory cache is
335+
* out of scope, and callers asking for `withTransaction` are asking for
336+
* atomicity, which only the durable can provide. Inside `fn`, reads and
337+
* writes go straight through the durable's transaction handle.
338+
*
339+
* After `fn` resolves and the durable commits, the cache may hold stale
340+
* rows for any keys the transaction mutated; subsequent reads through
341+
* this wrapper repopulate the cache on miss, so callers who need the
342+
* cache hot for those rows should issue a read-through after the
343+
* transaction resolves. Inheriting `BaseTabularStorage`'s no-op default
344+
* here would silently lose the rollback / atomicity guarantee, since the
345+
* default just runs `fn(this)` against the cached wrapper itself.
346+
*/
347+
override async withTransaction<T>(fn: (tx: this) => Promise<T>): Promise<T> {
348+
await this.initializeCache();
349+
return this.durable.withTransaction(
350+
fn as unknown as (
351+
tx: ITabularStorage<Schema, PrimaryKeyNames, Entity, PrimaryKey>
352+
) => Promise<T>
353+
);
354+
}
355+
331356
/**
332357
* Invalidates the cache by clearing it and resetting initialization flag
333358
*/

packages/storage/src/tabular/ITabularStorage.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -306,32 +306,44 @@ export interface ITabularStorage<
306306
*
307307
* Backends differ in how strong the guarantee is:
308308
* - **SQLite**: real `BEGIN` / `COMMIT` / `ROLLBACK`.
309-
* - **PostgreSQL**: real `BEGIN` / `COMMIT` / `ROLLBACK` only when the
310-
* underlying handle is a single-connection wrapper (PGLitePool, raw
311-
* PGlite). On a multi-connection `pg.Pool` the call throws, because
312-
* methods on this storage dispatch each query through the pool
313-
* independently and the surrounding `BEGIN`/`COMMIT` cannot bracket
314-
* them. Use {@link putBulk} for atomic bulk inserts on a real pool, or
315-
* wrap the pool yourself.
309+
* - **PostgreSQL**: real `BEGIN` / `COMMIT` / `ROLLBACK`. On a real
310+
* `pg.Pool` (anything exposing `connect()`) the implementation
311+
* dedicates a client via `pool.connect()` and runs the transaction on
312+
* that client, leaving the parent's pool free for external traffic
313+
* in parallel. On single-connection wrappers (PGLitePool, raw PGlite)
314+
* the transaction runs on the shared session and concurrent calls on
315+
* the same instance are serialized behind a per-instance mutex so
316+
* they cannot slip into the open transaction.
316317
* - **Supabase, in-memory, file system, IndexedDB**: best-effort. The
317318
* callback runs to completion and rejection propagates, but partial
318319
* writes are not rolled back because the backend does not expose a
319320
* transaction surface usable by this API.
320321
*
321-
* **Concurrency contract:** `withTransaction` does not isolate the storage
322-
* instance from concurrent callers. While `fn` is awaiting, any unrelated
323-
* operation invoked on the same storage instance from concurrent code will
324-
* run on the same underlying connection (for SQLite and single-connection
325-
* Postgres wrappers) and become part of this transaction. Do not invoke
326-
* other methods on the same storage instance from outside `fn` while a
327-
* `withTransaction` call is in flight; serialize that work yourself, or
328-
* use a separate storage instance for the concurrent work.
322+
* **Concurrency contract:**
323+
* - On backends with native transaction support (SQLite, PostgreSQL),
324+
* concurrent calls on the same storage instance are isolated from the
325+
* open transaction: SQLite and the single-connection Postgres path
326+
* serialize them through a per-instance mutex; the real-pool Postgres
327+
* path runs them on independent pool clients in parallel. Either way,
328+
* unrelated writes never accidentally commit or roll back along with
329+
* `fn`.
330+
* - On best-effort backends concurrent writes have no atomicity barrier
331+
* to begin with — the contract on those backends is "runs `fn`", not
332+
* "isolates `fn`".
329333
*
330-
* The storage instance passed to `fn` is the same instance (`this`) for
331-
* every backend in this codebase. The `tx` parameter is provided so the
332-
* callback signature stays forward-compatible with a future backend that
333-
* returns a transaction-scoped clone — callers should use the handle and
334-
* not capture `this` directly.
334+
* The `tx` handle passed to `fn` is **not** the same object as `this` for
335+
* backends with native transaction support — it is a Proxy that routes
336+
* writes through the transaction-bound resources (the dedicated client on
337+
* real `pg.Pool`, the bypass-mutex internal methods on SQLite/PGlite) and
338+
* routes events through the transaction's deferred-emit queue. Callers
339+
* MUST use `tx` for everything inside `fn`. Capturing the outer `this` and
340+
* calling methods on it from inside `fn` will deadlock against the held
341+
* mutex (single-connection backends) or run on the wrong connection
342+
* (`pg.Pool`), and is unsupported.
343+
*
344+
* Nested `withTransaction` calls — either via the original instance or
345+
* via `tx` — throw rather than reusing the outer transaction implicitly.
346+
* Use SAVEPOINT directly if you need nested rollback boundaries.
335347
*/
336348
withTransaction<T>(fn: (tx: this) => Promise<T>): Promise<T>;
337349

packages/storage/src/tabular/TelemetryTabularStorage.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,20 @@ export class TelemetryTabularStorage<
137137
}
138138

139139
withTransaction<T>(fn: (tx: this) => Promise<T>): Promise<T> {
140+
// Construct a tx-bound telemetry wrapper that forwards to the inner's
141+
// transaction handle (`innerTx`) — the proxy from the inner's
142+
// `createTxView`. Passing the outer `this` would re-enter `inner.put()`
143+
// through the public, mutex-acquiring path and deadlock against the held
144+
// mutex on single-connection backends, or run on the wrong connection on
145+
// real `pg.Pool`.
140146
return traced("workglow.storage.tabular.withTransaction", this.storageName, () =>
141-
this.inner.withTransaction(() => fn(this))
147+
this.inner.withTransaction((innerTx) => {
148+
const txWrapper = new TelemetryTabularStorage(
149+
this.storageName,
150+
innerTx as ITabularStorage<Schema, PrimaryKeyNames, Entity, PrimaryKey, InsertType>
151+
) as this;
152+
return fn(txWrapper);
153+
})
142154
);
143155
}
144156

0 commit comments

Comments
 (0)