OUT-4081: harden BaseService transaction handling - #282
Conversation
…nset pairing Wraps db.transaction in a finally that restores this (and any passed services), so a service is never left on a closed tx handle. Adds a unit test covering restore on success and on throw. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ansaction Behavior-preserving. Also binds syncLogService so product.created's logSync write is atomic with the mapping, and makes the product-map writes sequential since they now share one tx connection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tokenService and syncLogService were never bound to the tx, so the suspend-account and delete-failed-logs writes ran on the pool outside it (not atomic), and unsetTransaction was skipped on throw. Bind both services via withTransaction and run the writes sequentially. Caller is currently dormant, so no live prod exposure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThe PR centralizes transaction binding and cleanup in
Confidence Score: 5/5The PR appears safe to merge, with no concrete changed-code defect identified. The migrated paths bind all required services to one transaction, execute shared-connection writes sequentially, and reliably restore service database handles after commit or rollback. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant Service as BaseService subclass
participant DB as Database pool
participant TX as Transaction
participant Nested as Nested services
Caller->>Service: withTransaction(fn, services)
Service->>DB: transaction(callback)
DB-->>Service: tx handle
Service->>Service: setTransaction(tx)
Service->>Nested: setTransaction(tx)
Service->>Service: execute fn(tx)
Service-->>DB: return or throw
DB-->>Service: commit or rollback
Service->>Service: unsetTransaction()
Service->>Nested: unsetTransaction()
Service-->>Caller: result or error
Reviews (1): Last reviewed commit: "fix(OUT-4081): make checkAndSuspendAccou..." | Re-trigger Greptile |
priosshrsth
left a comment
There was a problem hiding this comment.
@SandipBajracharya One more concern I have. Say you ended the transaction. And you use another method from that service, what would happen?
| * bound to the tx, restored in a `finally` that wraps the transaction so none | ||
| * is left on a closed handle. | ||
| */ | ||
| protected async withTransaction<T>( |
There was a problem hiding this comment.
Can we change signature of the function to recieve services as object instead of array. And these services would be passed to fn. So that fn body can call the services from params.
My reasoning is this will help prevent missing the dependency. But let me know yuor opinion on this.
There was a problem hiding this comment.
I understand the concern and it's quite right to think that way. But even if we use object and pass param to fn(), I dont think we can prevent missing dependency. I think that invites complexity only. I might be wrong though.
Since the transaction is closed/unset, this.db gets its value reassigned by |
…s/ and fix links Adds docs/baseservice-transactions.md (why this.db is mutable, withTransaction, the nested-service footgun TS can't catch). Un-gitignores docs/ and publishes why-test-helpers-use-the-app-db-singleton.md. CLAUDE.md: summarize the tx pattern + footgun inline, correct the stale 'docs/ is gitignored' note, and drop dangling docs/*.md links. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Hardens DB transaction handling in
BaseServiceand fixes one latent atomicity bug it uncovered.BaseServiceswaps a mutablethis.dbbetween the connection pool and a per-tx handle viasetTransaction/unsetTransaction. Pairing was done by hand at each call site and was inconsistent — one site placed the unset inside the transaction callback, another didn't wrap it in afinallyat all (so a throw skipped it, leavingthis.dbon a closed handle).What changed
BaseService.withTransaction(fn, services?)— wrapsdb.transaction(...)in afinallythat restoresthisand any passed services, so a service can never be left on a dead tx handle. It also binds nested services to the tx explicitly (eachBaseServiceinstance has its ownthis.db, so nested writes are not transactional unless bound).product.service.ts(×2),invoice.service.ts,sync.service.ts.Bug vs. refactor (honest split)
fix—sync.service.ts#checkAndSuspendAccount(commit046bc74): the suspend-account and delete-failed-logs writes went through a freshTokenServiceandthis.syncLogServicethat were never bound to the tx — so despite living insidedb.transaction(...), they ran on the pool and weren't atomic;unsetTransactionwas also skipped on throw. Now both are bound and run sequentially (they share one tx connection). The caller is currently commented out incron.service.ts, so there's no live prod exposure — this is a latent bug on a dormant path.refactor(commitsaadbe33,a208761): the helper + unit test, and routing product/invoice through it (behavior-preserving). Also, as small cleanups the helper made cheap:product.created'slogSyncnow writes inside the tx, and the product-map writes are sequential rather thanPromise.allon the shared tx connection.Testing
test/unit/core/baseService.withTransaction.test.ts— proves set/unset are paired acrossthis+ nested services on both success and throw.yarn test— 78 files / 333 tests pass.tsc --noEmit(app) andtsc -p test/tsconfig.jsonclean;yarn lint:checkclean.Not in scope
Only the four existing transaction sites are touched. Pre-existing choice on whether concurrent queries on a reserved postgres-js tx connection are ever safe is left for a future decision.
🤖 Generated with Claude Code