diff --git a/resources/DatabaseTransaction.ts b/resources/DatabaseTransaction.ts index 6fefdc9fa..8b0045cd8 100644 --- a/resources/DatabaseTransaction.ts +++ b/resources/DatabaseTransaction.ts @@ -466,7 +466,7 @@ export class DatabaseTransaction implements Transaction { private completeDeferredContextRelease(): void { if (!this.pendingContextRelease) return; this.pendingContextRelease = false; - if (this.#context?.transaction === this) this.#context.transaction = null; + if (this.#context?.transaction === this) this.#context.transaction = RELEASED_TRANSACTION; } disregardReadTxn(): void { @@ -517,11 +517,9 @@ export class DatabaseTransaction implements Transaction { * alive (a fresh write on the same context must not join a DIFFERENT, already-replayed * transaction) until doneReadTxn() drains the last one, so the release is deferred to there. * - * Sets `.transaction` to `null` rather than deleting the property: `Context.transaction` is - * typed `DatabaseTransaction | null | undefined` precisely to document this released state, and - * `delete` would repeatedly force a long-lived, hot context (e.g. an MQTT subscription context - * releasing/reattaching a transaction per message) into V8's slower dictionary-mode property - * storage. + * Leaves RELEASED_TRANSACTION in the slot: the slot must stay callable (see that constant), and it + * must stay a plain assignment — `delete` repeatedly forces a long-lived, hot context into V8's + * dictionary-mode property storage. */ private releaseContext(final: boolean): void { if (!final) return; @@ -529,7 +527,7 @@ export class DatabaseTransaction implements Transaction { this.pendingContextRelease = true; return; } - if (this.#context?.transaction === this) this.#context.transaction = null; + if (this.#context?.transaction === this) this.#context.transaction = RELEASED_TRANSACTION; } checkOverloaded() { @@ -1194,6 +1192,57 @@ export class ImmediateTransaction extends DatabaseTransaction { } } +/** + * What `context.transaction` holds once its transaction has completed and released the back-reference + * (see releaseContext()). `commit()`/`abort()` are no-ops and reads through it see the latest + * committed state, so the documented `getContext().transaction.commit()` pattern stays callable after + * completion, reporting the same `txnTime: 0` that re-committing the completed transaction itself did + * (its own timestamp is reset by the commit that completed it). + * + * Not a DatabaseTransaction subclass and not extensible: one process-wide instance shared by every + * released context owns no mutable state, and anything off this surface fails loudly rather than + * inheriting behavior that would write through to every other context. + */ +const RELEASED_TRANSACTION_SURFACE = { + open: TRANSACTION_STATE.CLOSED, + transaction: undefined, + writes: Object.freeze([]), + commit(): CommitResolution { + return { txnTime: 0 }; + }, + abort(): void {}, + getReadTxn(): undefined { + return; // no transaction means read latest + }, + useReadTxn(): undefined { + return; + }, + doneReadTxn(): void {}, + disregardReadTxn(): void {}, + hasPendingWrites(): boolean { + return false; + }, + addWrite(): never { + throw new Error( + 'Cannot write to a transaction that has already completed; start a new one with transaction() or pass a fresh context' + ); + }, + setContext(): never { + throw new Error('Cannot attach a context to the shared released transaction'); + }, +}; +Object.freeze(RELEASED_TRANSACTION_SURFACE); +export const RELEASED_TRANSACTION = RELEASED_TRANSACTION_SURFACE as unknown as DatabaseTransaction; + +/** + * The placeholder means "this context has no transaction". Every reader that would otherwise act on the + * value — claim it for a store, adopt it as a context, treat it as data — must ask first, or it operates + * on the one instance every released context shares. + */ +export function isReleasedTransaction(value: unknown): boolean { + return value === RELEASED_TRANSACTION; +} + let timer; /** diff --git a/resources/Resource.ts b/resources/Resource.ts index 22d122c94..4dc22edb0 100644 --- a/resources/Resource.ts +++ b/resources/Resource.ts @@ -10,7 +10,12 @@ import { RequestTargetOrId, } from './ResourceInterface.ts'; import { randomUUID } from 'crypto'; -import { DatabaseTransaction, TRANSACTION_STATE, type Transaction } from './DatabaseTransaction.ts'; +import { + DatabaseTransaction, + isReleasedTransaction, + TRANSACTION_STATE, + type Transaction, +} from './DatabaseTransaction.ts'; import { IterableEventQueue } from './IterableEventQueue.ts'; import { _assignPackageExport } from '../globals.js'; import { ClientError, AccessViolation } from '../utility/errors/hdbError.ts'; @@ -224,6 +229,7 @@ export class Resource implements ResourceInterface< record = idPrefix; } } + if (isReleasedTransaction(context)) context = undefined; if (context) { if ((context as any).getContext) context = (context as any).getContext(); } else { @@ -589,6 +595,9 @@ function transactional( function applyContext(idOrQuery: string | Id | Query, dataOrContext?: any, context?: Context) { let id, query, isCollection; let data; + // An absent argument, not a context — and not this call's data either. + if (isReleasedTransaction(dataOrContext)) dataOrContext = undefined; + if (isReleasedTransaction(context)) context = undefined; // First we do our argument normalization. There are two main types of methods, with or without content if (hasContent) { // for put, post, patch, publish, query diff --git a/resources/ResourceInterface.ts b/resources/ResourceInterface.ts index 09b13716c..057f6274d 100644 --- a/resources/ResourceInterface.ts +++ b/resources/ResourceInterface.ts @@ -68,11 +68,13 @@ export interface Context { /** Describes the current cookie-based session if it is present and grants the capacity to delete it. authentication.enableSessions must be turned on in the harperdb-config.yaml */ session?: Session; /** - * The database transaction object. `undefined` means none was ever attached; `null` means one - * was attached and has since completed and released its back-reference (DatabaseTransaction.ts's - * releaseContext()) — kept `null` rather than deleting the property so a long-lived, hot context - * (e.g. an MQTT subscription context releasing/reattaching a transaction per message) doesn't - * repeatedly force V8 to deoptimize it into dictionary-mode property storage. + * The database transaction object. `undefined` means none was ever attached. On the RocksDB path, a + * completed transaction releases its back-reference (DatabaseTransaction.ts's releaseContext()) and + * the slot holds RELEASED_TRANSACTION, a shared completed transaction: always safe to call + * `commit()`/`abort()` on (both no-ops) and to read through (latest committed state, no snapshot), + * so code that commits mid-handler can keep using its context. LMDBTransaction does not release, + * so there the completed transaction itself stays in the slot — also safe to call, but retained. + * `null` was the previous released marker and is still accepted defensively. */ transaction?: DatabaseTransaction | null; /** If the operation that will be performed with this context should check user authorization */ diff --git a/resources/Table.ts b/resources/Table.ts index bcf0b5971..080f22f7b 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -36,6 +36,7 @@ import { DatabaseTransaction, ImmediateTransaction, priorStagedWrite, + isReleasedTransaction, TRANSACTION_STATE, } from './DatabaseTransaction.ts'; import * as envMngr from '../utility/environment/environmentManager.ts'; @@ -5597,6 +5598,7 @@ export function makeTable(options) { } function txnForContext(context: Context) { let transaction = context?.transaction; + if (isReleasedTransaction(transaction)) transaction = undefined; if (transaction) { if (!transaction.db && isRocksDB) { // this is an uninitialized DatabaseTransaction, we can claim it diff --git a/resources/transaction.ts b/resources/transaction.ts index 837455bd6..b2dd2abcd 100644 --- a/resources/transaction.ts +++ b/resources/transaction.ts @@ -1,6 +1,11 @@ import type { Context } from './ResourceInterface.ts'; import { _assignPackageExport } from '../globals.js'; -import { DatabaseTransaction, type Transaction, TRANSACTION_STATE } from './DatabaseTransaction.ts'; +import { + DatabaseTransaction, + isReleasedTransaction, + type Transaction, + TRANSACTION_STATE, +} from './DatabaseTransaction.ts'; import { AsyncLocalStorage } from 'async_hooks'; export const contextStorage = new AsyncLocalStorage(); @@ -25,8 +30,12 @@ export function transaction( asyncStorageContext = contextStorage.getStore(); context = asyncStorageContext ?? {}; } else { + // The released placeholder is an absent argument, not a context: normalized before the fallback + // chain below so it resolves to the ambient store exactly as the `null` it replaced did, rather + // than to a bare `{}` that would drop the caller's user, session and timestamp. + const contextArg = isReleasedTransaction(ctx) ? undefined : ctx; // request argument included, but null or undefined, so maybe create a new one - context = ctx ?? (asyncStorageContext = contextStorage.getStore()) ?? {}; + context = contextArg ?? (asyncStorageContext = contextStorage.getStore()) ?? {}; } if (typeof callback !== 'function') { @@ -75,6 +84,9 @@ export function transaction( _assignPackageExport('transaction', transaction); +// Only a context that never had a transaction has none to act on: a completed transaction still in the +// slot must no-op here, as it always did, or a checkpointing loop that commits every Nth row fails on +// its second checkpoint. transaction.commit = function (contextSource) { const transaction = (contextSource.getContext?.() || contextSource)?.transaction; if (!transaction) throw new Error('No active transaction is available to commit'); diff --git a/unitTests/resources/lingeringWriteCommit.test.js b/unitTests/resources/lingeringWriteCommit.test.js index bac2e5804..e75b8ba6c 100644 --- a/unitTests/resources/lingeringWriteCommit.test.js +++ b/unitTests/resources/lingeringWriteCommit.test.js @@ -4,7 +4,7 @@ const { setupTestDBPath } = require('../testUtils'); const { table } = require('#src/resources/databases'); const { setMainIsWorker } = require('#js/server/threads/manageThreads'); const { transaction } = require('#src/resources/transaction'); -const { TRANSACTION_STATE } = require('#src/resources/DatabaseTransaction'); +const { TRANSACTION_STATE, RELEASED_TRANSACTION } = require('#src/resources/DatabaseTransaction'); const { setTimeout: delay } = require('node:timers/promises'); // A commit issued while a search iterator still holds the read transaction cannot commit the @@ -61,7 +61,7 @@ describe('commit with open read iterators commits writes immediately on a replay const { context, iterator } = await commitWithOpenIterator('linger-write'); // Captured before the iterator drains: once it does, DatabaseTransaction now releases the // context's own back-reference too (releaseContext(), deferred here until the last iterator - // finishes — see doneReadTxn()), so context.transaction itself goes null. Assertions below + // finishes — see doneReadTxn()), so the slot stops pointing at this instance. Assertions below // that need the specific (now-closed) transaction instance use this captured reference. const closedTxn = context.transaction; assert.equal( @@ -78,7 +78,11 @@ describe('commit with open read iterators commits writes immediately on a replay while (!(await iterator.next()).done); await delay(50); // give a released-handle failure a beat to surface as an unhandledRejection assert.equal(closedTxn.transaction, null, 'the drained iterator must release the native handle'); - assert.equal(context.transaction, null, 'the drained iterator must also release the context’s back-reference'); + assert.equal( + context.transaction, + RELEASED_TRANSACTION, + 'the drained iterator must also release the context’s back-reference' + ); assert.ok(await LingerTable.get('linger-write'), 'releasing the read handle must not disturb the committed write'); // audit/txn-log entries batch on the native transaction they were staged into and are only // written by its commit; the replay must re-stage them into ITS transaction — the original @@ -143,14 +147,14 @@ describe('commit with open read iterators commits writes immediately on a replay assert.equal(await LingerTable.get('linger-fail'), undefined, 'the failed write is not committed'); // A terminal commit failure is just as final as a success: releaseContext() defers the // context release the same way (pendingContextRelease) until the still-open iterator drains, - // so capture the wrapper before draining it — draining deletes context.transaction itself. + // so capture the wrapper before draining it — draining replaces context.transaction itself. const closedTxn = context.transaction; // the original read handle must survive the replay failure: the iterator finishes normally while (!(await iterator.next()).done); assert.equal(closedTxn.transaction, null, 'the drained iterator must still release the native handle'); assert.equal( context.transaction, - null, + RELEASED_TRANSACTION, 'the drained iterator must also release the context’s back-reference, even after a terminal commit failure' ); await delay(100); diff --git a/unitTests/resources/operationContextTransactionLeak.test.js b/unitTests/resources/operationContextTransactionLeak.test.js index 838ffe51d..58de513f9 100644 --- a/unitTests/resources/operationContextTransactionLeak.test.js +++ b/unitTests/resources/operationContextTransactionLeak.test.js @@ -4,7 +4,7 @@ const { table } = require('#src/resources/databases'); const { setMainIsWorker } = require('#js/server/threads/manageThreads'); const serverUtilities = require('#src/server/serverHelpers/serverUtilities'); const { contextStorage } = require('#src/resources/transaction'); -const { TRANSACTION_STATE, DatabaseTransaction } = require('#src/resources/DatabaseTransaction'); +const { TRANSACTION_STATE, DatabaseTransaction, RELEASED_TRANSACTION } = require('#src/resources/DatabaseTransaction'); // Regression coverage for a transaction-context leak exposed by issue #1591/#1592 (ambient user // context for operation handlers) and confirmed against a real 2-node cluster-formation scenario @@ -200,17 +200,17 @@ describe('Ambient operation context must not couple independent writes (transact const [afterFirst, afterSecond, afterThird] = transactionsSeenAfterEachWrite; assert.strictEqual( afterFirst, - null, + RELEASED_TRANSACTION, "the first write's transaction must be released from the ambient context once its commit completes" ); assert.strictEqual( afterSecond, - null, + RELEASED_TRANSACTION, "the second write's transaction must likewise be released, not left attached for a later write to observe" ); assert.strictEqual( afterThird, - null, + RELEASED_TRANSACTION, "the third write's transaction must likewise be released, for the same reason" ); @@ -237,4 +237,29 @@ describe('Ambient operation context must not couple independent writes (transact 'the third write must not join the second write’s transaction instance, for the same reason' ); }); + + // The delivery path: an operations-API handler under processLocalTransaction's ambient context reads + // through that context and then commits it itself. transaction.test.js covers the same shape on a + // bare `{}` context; only this one exercises the real ambient operation context. + it('lets an operation handler commit its ambient context after a static read completed that context’s transaction', async () => { + let committed = false; + const result = await serverUtilities.processLocalTransaction( + { body: { operation: 'test_registered_op', hdb_user: { username: 'internal_bookkeeping' } } }, + async () => { + const context = contextStorage.getStore(); + await LeakTable.get('handler-commit-target'); + // getUserPermissions()'s shape: bound the transaction the reads above ran in, then carry on. + await context.transaction.commit(); + committed = true; + await LeakTable.put('handler-commit-target', { name: 'written after the handler commit' }); + await context.transaction.commit(); + return { message: 'ok' }; + } + ); + assert.equal(result?.message, 'ok', 'the handler must not fail on its own mid-handler commit'); + assert.ok(committed); + const record = await LeakTable.get('handler-commit-target'); + assert.ok(record, 'the write made after the handler’s own commit must persist'); + assert.equal(record.name, 'written after the handler commit'); + }); }); diff --git a/unitTests/resources/transaction.test.js b/unitTests/resources/transaction.test.js index c0cb5fc57..38dd5024c 100644 --- a/unitTests/resources/transaction.test.js +++ b/unitTests/resources/transaction.test.js @@ -4,8 +4,9 @@ const { setTimeout: delay } = require('node:timers/promises'); const { setupTestDBPath } = require('../testUtils'); const { table } = require('#src/resources/databases'); const { setMainIsWorker } = require('#js/server/threads/manageThreads'); -const { transaction } = require('#src/resources/transaction'); -const { DatabaseTransaction } = require('#src/resources/DatabaseTransaction'); +const { transaction, contextStorage } = require('#src/resources/transaction'); +const serverUtilities = require('#src/server/serverHelpers/serverUtilities'); +const { DatabaseTransaction, RELEASED_TRANSACTION, TRANSACTION_STATE } = require('#src/resources/DatabaseTransaction'); const { LMDBTransaction } = require('#src/resources/LMDBTransaction'); const { IterableEventQueue } = require('#src/resources/IterableEventQueue'); const { RocksDatabase } = require('@harperfast/rocksdb-js'); @@ -226,11 +227,7 @@ describe('Transactions', () => { } assert.equal(entries[0].name, 'thirteen'); await TxnTest3.put(14, { name: 'fourteen' }, context); - // context.transaction may already be released here: the preceding get()/search() calls each ran - // (and durably committed) their own short-lived transaction once the explicit commit above closed - // the original one, and DatabaseTransaction now releases the context's back-reference as soon as - // one of those completes — so there may be nothing left to explicitly commit. - await context.transaction?.commit(); + await context.transaction.commit(); assert.equal((await TxnTest.get(7, context)).name, 'SEVEN'); assert.equal((await TxnTest2.get(13, context)).name, 'thirteen'); assert.equal((await TxnTest3.get(14, context)).name, 'fourteen'); @@ -827,10 +824,7 @@ describe('Transactions', () => { } await context.transaction.commit(); await TxnTest.put({ id: 8, name: 'eight changed' }); // no context - // context.transaction may already be released here — the ambient put above ran (and durably - // committed) its own short-lived transaction once the explicit commit above closed the - // original one; see the identical comment in 'Can run txn with commit in the middle'. - await context.transaction?.commit(); + await context.transaction.commit(); assert.equal((await TxnTest.get(8, context)).name, 'eight changed'); }); }); @@ -847,10 +841,7 @@ describe('Transactions', () => { await transaction(context, async () => { await TxnTest.put(90, { name: 'release-on-commit' }, context); }); - // releaseContext() nulls the slot rather than deleting it — Context.transaction is typed - // `DatabaseTransaction | null | undefined` to document a released-but-previously-attached - // state, so a long-lived, hot context isn't repeatedly forced into V8 dictionary mode. - assert.strictEqual(context.transaction, null); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); assert.equal((await TxnTest.get(90)).name, 'release-on-commit'); }); it('releases the context’s back-reference once the transaction aborts', async function () { @@ -862,7 +853,7 @@ describe('Transactions', () => { }), /forced abort for test/ ); - assert.strictEqual(context.transaction, null); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); assert.equal(await TxnTest.get(91), undefined); }); it('does not clobber a context that has been re-pointed at a different transaction', async function () { @@ -889,25 +880,23 @@ describe('Transactions', () => { await transaction(context, async () => { await TxnTest.put(92, { name: 'first txn on shared context' }, context); }); - assert.strictEqual(context.transaction, null); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); await transaction(context, async () => { await TxnTest.put(93, { name: 'second txn on shared context' }, context); }); assert.equal((await TxnTest.get(92)).name, 'first txn on shared context'); assert.equal((await TxnTest.get(93)).name, 'second txn on shared context'); }); - // The release must not strand a later write on an uncommitted transaction. It doesn't, and the - // reason is worth pinning: resources/transaction.ts:35 only REUSES a context's transaction when - // it is still OPEN, so a retained CLOSED one was never reused for a subsequent write anyway — - // line 39 minted a fresh DatabaseTransaction either way. Nulling it therefore changes only what - // stays reachable, not how the next write is serviced: it still goes through the transaction() - // wrapper, which commits in onComplete (or aborts in onError) by construction. + // The release must not strand a later write on an uncommitted transaction. It cannot: + // resources/transaction.ts only reuses a context's transaction while it is OPEN, so a later write + // always goes through the transaction() wrapper, which commits in onComplete (or aborts in + // onError) by construction. it('keeps post-completion writes on a reused context durable, with nothing left staged', async function () { const context = {}; await transaction(context, async () => { await TxnTest.put(94, { name: 'inside txn' }, context); }); - assert.strictEqual(context.transaction, null); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); // Writes made with the SAME context after its transaction completed must still commit. await TxnTest.put(95, { name: 'after commit' }, context); await TxnTest.get(95, context); // a read in between, which also re-enters the dispatcher @@ -921,6 +910,130 @@ describe('Transactions', () => { 'no write may be left staged on an uncommitted transaction attached to the reused context' ); }); + // A handler with no transaction() wrapper of its own makes a static Resource API call with its + // context; Resource.ts services that by minting a transaction ON the context and driving it to a + // final commit. The handler may then still commit its own context, per the documented pattern. + it('lets a handler commit its context after a nested Resource API call completed that context’s transaction', async function () { + const context = {}; + await TxnTest.get(97, context); + assert.strictEqual( + context.transaction, + RELEASED_TRANSACTION, + 'premise: the nested get’s own final commit released the slot' + ); + await context.transaction.commit(); + await TxnTest.put(97, { name: 'after released commit' }, context); + await context.transaction.commit(); + assert.equal((await TxnTest.get(97)).name, 'after released commit'); + }); + // A shared, process-wide released transaction must never be claimed as a place to stage writes. + it('never lets the released placeholder be claimed or written to', async function () { + const context = {}; + await transaction(context, async () => { + await TxnTest.put(98, { name: 'claim check' }, context); + }); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); + await TxnTest.put(99, { name: 'not staged on the placeholder' }, context); + // The write runs on a transaction of its own, which releases the slot back to the placeholder, + // so the slot's identity says nothing here — only that the shared instance was never claimed. + assert.equal(RELEASED_TRANSACTION.writes.length, 0, 'nothing may ever be staged on the placeholder'); + assert.equal(RELEASED_TRANSACTION.db, undefined, 'no store may ever claim the placeholder'); + assert.equal((await TxnTest.get(99)).name, 'not staged on the placeholder'); + }); + // One instance is shared by every released context, so each route into its state must fail rather + // than write through to all of them. + it('refuses every route into the shared placeholder’s state', function () { + assert.throws(() => RELEASED_TRANSACTION.addWrite({}), /already completed/); + assert.throws(() => RELEASED_TRANSACTION.setContext({}), /shared released transaction/); + assert.throws(() => RELEASED_TRANSACTION.writes.push({}), TypeError); + assert.throws(() => { + 'use strict'; + RELEASED_TRANSACTION.open = TRANSACTION_STATE.OPEN; + }, TypeError); + assert.equal(RELEASED_TRANSACTION.open, TRANSACTION_STATE.CLOSED); + }); + it('keeps transaction.commit()/abort() throwing for a context that never had a transaction', function () { + assert.throws(() => transaction.commit({}), /No active transaction is available to commit/); + assert.throws(() => transaction.abort({}), /No active transaction is available to abort/); + }); + it('makes transaction.commit(context) a no-op on a released slot, like the direct form', async function () { + const context = {}; + await transaction(context, async () => { + await TxnTest.put(100, { name: 'live' }, context); + }); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); + assert.deepEqual(await transaction.commit(context), { txnTime: 0 }); + assert.equal(transaction.abort(context), undefined); + assert.equal((await TxnTest.get(100)).name, 'live'); + }); + it('stays callable for repeated checkpoint commits inside one scope', async function () { + const context = {}; + await transaction(context, async () => { + for (let i = 0; i < 4; i++) { + await TxnTest.put(110 + i, { name: `checkpoint-${i}` }, context); + await transaction.commit(context); + } + }); + for (let i = 0; i < 4; i++) { + assert.equal((await TxnTest.get(110 + i))?.name, `checkpoint-${i}`, `row ${i} must persist`); + } + }); + // Passing a transaction where a context is expected is a supported form, and on a released context + // that argument is the placeholder. Every route that adopts one has to read it as an absent + // argument — Resource.create shifts its own arguments and never reaches transactional()'s + // normalizer, and transaction() can be handed it directly. + it('accepts a released slot wherever a transaction or context is accepted', async function () { + const context = {}; + await transaction(context, async () => { + await TxnTest.put(120, { name: 'bare-arg' }, context); + }); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); + await TxnTest.delete(120, context.transaction); + assert.equal(await TxnTest.get(120), undefined, 'the delete must run on a fresh transaction'); + const createdId = await TxnTest.create({ name: 'created-with-released-slot' }, context.transaction); + assert.equal((await TxnTest.get(createdId))?.name, 'created-with-released-slot', 'the create must persist'); + await transaction(context.transaction, async () => TxnTest.put(121, { name: 'direct' })); + assert.equal((await TxnTest.get(121)).name, 'direct'); + }); + // A released slot must also be a clean start for a MULTI-STORE transaction: the chain the previous + // transaction built (`next`) went with its commit, so the next one has to build its own. + it('starts a fresh multi-store chain after the slot is released', async function () { + const context = {}; + await transaction(context, async () => { + await TxnTest.put(140, { name: 'store-a' }, context); + await TxnTest2.put(140, { name: 'store-b' }, context); + }); + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); + await transaction(context, async () => { + await TxnTest.put(141, { name: 'store-a-again' }, context); + await TxnTest2.put(141, { name: 'store-b-again' }, context); + assert.equal(context.transaction.next?.next, undefined, 'the chain must not carry links over'); + }); + assert.equal((await TxnTest.get(141)).name, 'store-a-again'); + assert.equal((await TxnTest2.get(141)).name, 'store-b-again'); + }); + // "Absent" has to mean the same thing at every route: an absent argument falls through to the + // ambient context. Resolving the placeholder to a bare `{}` instead would silently drop the + // caller's user, session and timestamp for everything inside. + it('keeps the ambient context when a released slot is passed as the context', async function () { + const seen = {}; + await serverUtilities.processLocalTransaction( + { body: { operation: 'test_registered_op', hdb_user: { username: 'ambient_identity' } } }, + async () => { + const context = contextStorage.getStore(); + await TxnTest.get(130); // completes and releases this context's transaction + assert.strictEqual(context.transaction, RELEASED_TRANSACTION); + await transaction(context.transaction, async () => { + seen.user = contextStorage.getStore()?.user?.username; + await TxnTest.put(130, { name: 'ambient' }); + }); + await TxnTest.create({ name: 'created-ambient' }, context.transaction); + return { message: 'ok' }; + } + ); + assert.equal(seen.user, 'ambient_identity', 'the ambient user must survive the released-slot route'); + assert.equal((await TxnTest.get(130)).name, 'ambient'); + }); // #1411: a timeout-poisoned abort must NOT release the context's back-reference. Resource.ts's // dispatcher deliberately keeps joining a `timedOut` transaction (context?.transaction?.timedOut) // so the rest of the logical operation fails atomically, instead of silently starting a fresh