From 24c2fefd60c45ab0e143c922660ff08675644366 Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Wed, 9 Sep 2026 21:44:46 +0200 Subject: [PATCH 1/6] Expose attempt and retry metadata on Effect.Transaction Co-authored-by: Cursor --- .changeset/tx-transaction-attempt-metadata.md | 5 + packages/effect/src/Effect.ts | 178 +++++++++++++----- packages/effect/src/TxRef.ts | 8 +- packages/effect/test/Effect.test.ts | 79 ++++++++ 4 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 .changeset/tx-transaction-attempt-metadata.md diff --git a/.changeset/tx-transaction-attempt-metadata.md b/.changeset/tx-transaction-attempt-metadata.md new file mode 100644 index 00000000000..7f1f9d96c74 --- /dev/null +++ b/.changeset/tx-transaction-attempt-metadata.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Record attempt count, retry reason, and elapsed timing on `Effect.Transaction` as `TransactionMeta` so transaction bodies can observe retries and conflicts. diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index b3139bfe933..354830b31fd 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -19,7 +19,7 @@ import type * as ExecutionPlan from "./ExecutionPlan.ts" import * as Exit from "./Exit.ts" import type { Fiber } from "./Fiber.ts" import type * as Filter from "./Filter.ts" -import { constant, dual, type LazyArg } from "./Function.ts" +import { dual, type LazyArg } from "./Function.ts" import type { TypeLambda } from "./HKT.ts" import type { Inspectable } from "./Inspectable.ts" import * as core from "./internal/core.ts" @@ -14471,48 +14471,108 @@ export const trackDuration: { // Transactions // ----------------------------------------------------------------------------- +/** + * Why the current transaction attempt started after a previous attempt did not commit. + * + * **Details** + * + * `"retry"` means the previous attempt called {@link txRetry}. `"conflict"` + * means an accessed `TxRef` changed before commit. The first attempt has no + * retry reason. + * + * @category models + * @since 4.0.0 + */ +export type TransactionRetryReason = "retry" | "conflict" + +/** + * Journal, retry flag, and attempt metadata for an Effect transaction. + * + * **When to use** + * + * Use to type the value provided by {@link Transaction}. + * + * **Details** + * + * `attempt` starts at `1` and counts each run of the outermost transaction body. + * `retryReason` is `"retry"` after {@link txRetry} and `"conflict"` after an + * accessed `TxRef` changes before commit; it is unset on the first attempt. + * `start`, `now`, `elapsed`, and `elapsedSincePrevious` are millisecond + * timestamps and durations for the current attempt, matching {@link Schedule} + * metadata. + * + * @see {@link Transaction} for the context service that provides this metadata + * @see {@link TransactionRetryReason} for the retry-reason tags recorded on later attempts + * + * @category models + * @since 4.0.0 + */ +export interface TransactionMeta { + readonly retry: boolean + readonly journal: ReadonlyMap< + TxRef, + { + readonly version: number + readonly value: any + } + > + readonly attempt: number + readonly retryReason: TransactionRetryReason | undefined + readonly start: number + readonly now: number + readonly elapsed: number + readonly elapsedSincePrevious: number +} + +/** @internal */ +export interface TransactionMetaInner { + retry: boolean + journal: Map< + TxRef, + { + readonly version: number + value: any + } + > + attempt: number + retryReason: TransactionRetryReason | undefined + start: number + now: number + elapsed: number + elapsedSincePrevious: number +} + /** * Service that holds the current transaction state. * * **Details** * - * It includes a journal that stores non-committed changes to `TxRef` values and - * a retry flag that records whether the transaction should be retried. + * Nested {@link tx} calls reuse this same service instance. Yield it inside a + * transaction body to read {@link TransactionMeta} fields such as `attempt` + * and `retryReason`. * - * **Example** (Building transactions) + * **Example** (Reading transaction metadata) * * ```ts import.meta.vitest * import { Effect } from "effect" * - * // Transaction class for software transactional memory operations - * const txEffect = Effect.gen(function*() { + * const program = Effect.tx(Effect.gen(function*() { * const tx = yield* Effect.Transaction - * // Use transaction for coordinated state changes - * return "Transaction complete" - * }) + * return [tx.attempt, tx.retryReason, tx.elapsed, tx.elapsedSincePrevious] + * })) * - * const runnable = Effect.provideService(txEffect, Effect.Transaction, { - * retry: false, - * journal: new Map() - * }) - * Effect.runSync(runnable) // => "Transaction complete" + * Effect.runSync(program) // => [1, undefined, 0, 0] * ``` * + * @see {@link tx} for the outermost transaction boundary that creates this service + * @see {@link TransactionMeta} for the journal, retry flag, and attempt metadata + * * @category services * @since 4.0.0 */ export class Transaction extends Context.Service< Transaction, - { - retry: boolean - readonly journal: Map< - TxRef, - { - readonly version: number - value: any - } - > - } + TransactionMeta >()("effect/Effect/Transaction") {} /** @@ -14565,45 +14625,66 @@ export const tx = ( effect: Effect ): Effect> => withFiber((fiber) => { - let state = Context.getOrUndefined(fiber.context, Transaction) + const state = Context.getOrUndefined(fiber.context, Transaction) if (state) { return effect as Effect> } + const clock = fiber.getRef(internal.ClockRef) + const now = clock.currentTimeMillisUnsafe() // Create transaction state only at the outermost boundary - state = { journal: new Map(), retry: false } + const mutable: TransactionMetaInner = { + journal: new Map(), + retry: false, + attempt: 1, + retryReason: undefined, + start: now, + now, + elapsed: 0, + elapsedSincePrevious: 0 + } let result: Exit.Exit | undefined - return uninterruptibleMask((restore) => - flatMap( + return uninterruptibleMask((restore) => { + const run = restore(effect).pipe( + provideService(Transaction, mutable), + tapCause(() => { + if (!mutable.retry) return void_ + return restore(awaitPendingTransaction(mutable)) + }), + exit + ) + return flatMap( whileLoop({ while: () => !result, - body: constant( - restore(effect).pipe( - provideService(Transaction, state), - tapCause(() => { - if (!state.retry) return void_ - return restore(awaitPendingTransaction(state)) - }), - exit - ) - ), + body: () => { + stampTransaction(mutable, clock.currentTimeMillisUnsafe()) + return run + }, step(exit: Exit.Exit) { - if (state.retry || !isTransactionConsistent(state)) { - return clearTransaction(state) + if (mutable.retry || !isTransactionConsistent(mutable)) { + mutable.attempt++ + mutable.retryReason = mutable.retry ? "retry" : "conflict" + return clearTransaction(mutable) } if (Exit.isSuccess(exit)) { - commitTransaction(fiber, state) + commitTransaction(fiber, mutable) } else { - clearTransaction(state) + clearTransaction(mutable) } result = exit } }), () => result! ) - ) + }) }) -const isTransactionConsistent = (state: Transaction["Service"]) => { +const stampTransaction = (state: TransactionMetaInner, now: number) => { + state.elapsedSincePrevious = now - state.now + state.now = now + state.elapsed = now - state.start +} + +const isTransactionConsistent = (state: TransactionMeta) => { for (const [ref, { version }] of state.journal) { if (ref.version !== version) { return false @@ -14612,7 +14693,7 @@ const isTransactionConsistent = (state: Transaction["Service"]) => { return true } -const awaitPendingTransaction = (state: Transaction["Service"]) => +const awaitPendingTransaction = (state: TransactionMeta) => suspend(() => { const key = {} const refs = Array.from(state.journal.keys()) @@ -14633,7 +14714,7 @@ const awaitPendingTransaction = (state: Transaction["Service"]) => }) }) -function commitTransaction(fiber: Fiber, state: Transaction["Service"]) { +function commitTransaction(fiber: Fiber, state: TransactionMeta) { for (const [ref, { value }] of state.journal) { if (value !== ref.value) { ref.version = ref.version + 1 @@ -14646,7 +14727,7 @@ function commitTransaction(fiber: Fiber, state: Transaction["S } } -function clearTransaction(state: Transaction["Service"]) { +function clearTransaction(state: TransactionMetaInner) { state.retry = false state.journal.clear() } @@ -14690,7 +14771,8 @@ function clearTransaction(state: Transaction["Service"]) { export const txRetry: Effect = flatMap( Transaction, (state) => { - state.retry = true + const mutable = state as TransactionMetaInner + mutable.retry = true return interrupt } ) diff --git a/packages/effect/src/TxRef.ts b/packages/effect/src/TxRef.ts index 4d2a9798d7c..5b921294e6c 100644 --- a/packages/effect/src/TxRef.ts +++ b/packages/effect/src/TxRef.ts @@ -12,6 +12,7 @@ * @since 4.0.0 */ import * as Effect from "./Effect.ts" +import type { TransactionMetaInner } from "./Effect.ts" import { dual } from "./Function.ts" import { pipeArguments } from "./Pipeable.ts" import type { Pipeable } from "./Pipeable.ts" @@ -173,10 +174,11 @@ export const modify: { Effect.Transaction.pipe( Effect.flatMap((state) => Effect.sync(() => { - if (!state.journal.has(self)) { - state.journal.set(self, { version: self.version, value: self.value }) + const inner = state as TransactionMetaInner + if (!inner.journal.has(self)) { + inner.journal.set(self, { version: self.version, value: self.value }) } - const current = state.journal.get(self)! + const current = inner.journal.get(self)! const [returnValue, next] = f(current.value) current.value = next return returnValue diff --git a/packages/effect/test/Effect.test.ts b/packages/effect/test/Effect.test.ts index ad49fa1cebe..e696e86a8f8 100644 --- a/packages/effect/test/Effect.test.ts +++ b/packages/effect/test/Effect.test.ts @@ -3433,6 +3433,85 @@ describe("Effect", () => { assert.strictEqual(transactionValue, 20) })) }) + + describe("transaction metadata", () => { + it.effect("tracks nested metadata across retries and conflicts", () => + Effect.gen(function*() { + const ref = TxRef.makeUnsafe(0) + const started = yield* Deferred.make() + const readyForConflict = yield* Deferred.make() + const resume = yield* Deferred.make() + const snapshots = yield* Ref.make< + Array<{ + readonly attempt: number + readonly retryReason: Effect.TransactionRetryReason | undefined + readonly elapsed: number + readonly elapsedSincePrevious: number + readonly nestedSame: boolean + }> + >([]) + + const fiber = yield* Effect.forkChild(Effect.tx(Effect.gen(function*() { + const outer = yield* Effect.Transaction + const nestedSame = yield* Effect.tx(Effect.gen(function*() { + const inner = yield* Effect.Transaction + return inner === outer + })) + yield* Ref.update(snapshots, (current) => [ + ...current, + { + attempt: outer.attempt, + retryReason: outer.retryReason, + elapsed: outer.elapsed, + elapsedSincePrevious: outer.elapsedSincePrevious, + nestedSame + } + ]) + yield* TxRef.get(ref) + if (outer.attempt === 1) { + yield* Deferred.succeed(started, undefined) + return yield* Effect.txRetry + } + if (outer.attempt === 2) { + yield* Deferred.succeed(readyForConflict, undefined) + yield* Deferred.await(resume) + } + }))) + + yield* Deferred.await(started) + yield* TestClock.adjust("1 second") + yield* Effect.tx(TxRef.set(ref, 1)) + yield* Deferred.await(readyForConflict) + yield* Effect.tx(TxRef.set(ref, 2)) + yield* Deferred.succeed(resume, undefined) + yield* Fiber.join(fiber) + const recorded = yield* Ref.get(snapshots) + + assert.deepStrictEqual(recorded, [ + { + attempt: 1, + retryReason: undefined, + elapsed: 0, + elapsedSincePrevious: 0, + nestedSame: true + }, + { + attempt: 2, + retryReason: "retry", + elapsed: 1000, + elapsedSincePrevious: 1000, + nestedSame: true + }, + { + attempt: 3, + retryReason: "conflict", + elapsed: 1000, + elapsedSincePrevious: 0, + nestedSame: true + } + ]) + })) + }) }) describe("Effect.fn", () => { From c3041d28424566ba178337a0861f08044331af7c Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Wed, 9 Sep 2026 21:48:44 +0200 Subject: [PATCH 2/6] test --- packages/effect/test/Effect.test.ts | 15 +++++++++++++-- packages/effect/test/Effectable.test.ts | 3 +++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/effect/test/Effect.test.ts b/packages/effect/test/Effect.test.ts index e696e86a8f8..53b3897eb06 100644 --- a/packages/effect/test/Effect.test.ts +++ b/packages/effect/test/Effect.test.ts @@ -3445,6 +3445,8 @@ describe("Effect", () => { Array<{ readonly attempt: number readonly retryReason: Effect.TransactionRetryReason | undefined + readonly start: number + readonly now: number readonly elapsed: number readonly elapsedSincePrevious: number readonly nestedSame: boolean @@ -3462,6 +3464,8 @@ describe("Effect", () => { { attempt: outer.attempt, retryReason: outer.retryReason, + start: outer.start, + now: outer.now, elapsed: outer.elapsed, elapsedSincePrevious: outer.elapsedSincePrevious, nestedSame @@ -3482,6 +3486,7 @@ describe("Effect", () => { yield* TestClock.adjust("1 second") yield* Effect.tx(TxRef.set(ref, 1)) yield* Deferred.await(readyForConflict) + yield* TestClock.adjust("500 millis") yield* Effect.tx(TxRef.set(ref, 2)) yield* Deferred.succeed(resume, undefined) yield* Fiber.join(fiber) @@ -3491,6 +3496,8 @@ describe("Effect", () => { { attempt: 1, retryReason: undefined, + start: 0, + now: 0, elapsed: 0, elapsedSincePrevious: 0, nestedSame: true @@ -3498,6 +3505,8 @@ describe("Effect", () => { { attempt: 2, retryReason: "retry", + start: 0, + now: 1000, elapsed: 1000, elapsedSincePrevious: 1000, nestedSame: true @@ -3505,8 +3514,10 @@ describe("Effect", () => { { attempt: 3, retryReason: "conflict", - elapsed: 1000, - elapsedSincePrevious: 0, + start: 0, + now: 1500, + elapsed: 1500, + elapsedSincePrevious: 500, nestedSame: true } ]) diff --git a/packages/effect/test/Effectable.test.ts b/packages/effect/test/Effectable.test.ts index bd29da0ad3d..db5cdb586b4 100644 --- a/packages/effect/test/Effectable.test.ts +++ b/packages/effect/test/Effectable.test.ts @@ -29,6 +29,9 @@ describe("Effectable", () => { return Effect.fail(this.value) } } + type asd = Effect.Success + type asd2 = Effect.Error + type asd3 = Effect.Services assert.deepStrictEqual(yield* Effect.exit(new FailingBox(1)), Exit.fail(1)) })) From 1c91401166718ebe8c0ba1fb9f0f24afdfc58a0b Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Thu, 10 Sep 2026 12:38:18 +0200 Subject: [PATCH 3/6] changeset --- .changeset/tx-transaction-attempt-metadata.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tx-transaction-attempt-metadata.md b/.changeset/tx-transaction-attempt-metadata.md index 7f1f9d96c74..8cb94990d76 100644 --- a/.changeset/tx-transaction-attempt-metadata.md +++ b/.changeset/tx-transaction-attempt-metadata.md @@ -2,4 +2,4 @@ "effect": patch --- -Record attempt count, retry reason, and elapsed timing on `Effect.Transaction` as `TransactionMeta` so transaction bodies can observe retries and conflicts. +`Effect.Transaction` now stores attempt metadata, analogous to `Schedule.CurrentMetadata`. Yield it inside a transaction body to read `attempt`, `retryReason`, and the same timing fields as a schedule (`start`, `now`, `elapsed`, `elapsedSincePrevious`). From 095a3a2b62d7f02fe9e5d8ec349693d8d7ed21b2 Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Thu, 10 Sep 2026 12:43:20 +0200 Subject: [PATCH 4/6] remove retry from the public interface --- packages/effect/src/Effect.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index 354830b31fd..b85e741ce7f 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -14486,7 +14486,7 @@ export const trackDuration: { export type TransactionRetryReason = "retry" | "conflict" /** - * Journal, retry flag, and attempt metadata for an Effect transaction. + * Journal and attempt metadata for an Effect transaction. * * **When to use** * @@ -14508,7 +14508,6 @@ export type TransactionRetryReason = "retry" | "conflict" * @since 4.0.0 */ export interface TransactionMeta { - readonly retry: boolean readonly journal: ReadonlyMap< TxRef, { @@ -14565,7 +14564,7 @@ export interface TransactionMetaInner { * ``` * * @see {@link tx} for the outermost transaction boundary that creates this service - * @see {@link TransactionMeta} for the journal, retry flag, and attempt metadata + * @see {@link TransactionMeta} for the journal and attempt metadata * * @category services * @since 4.0.0 From 0f6e934fa0886a0d2ad369d896d35ad0c50abbdb Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Thu, 10 Sep 2026 12:46:16 +0200 Subject: [PATCH 5/6] remove useless Effect.tx --- packages/effect/test/Effect.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/effect/test/Effect.test.ts b/packages/effect/test/Effect.test.ts index 53b3897eb06..28903b01be0 100644 --- a/packages/effect/test/Effect.test.ts +++ b/packages/effect/test/Effect.test.ts @@ -3484,10 +3484,10 @@ describe("Effect", () => { yield* Deferred.await(started) yield* TestClock.adjust("1 second") - yield* Effect.tx(TxRef.set(ref, 1)) + yield* TxRef.set(ref, 1) yield* Deferred.await(readyForConflict) yield* TestClock.adjust("500 millis") - yield* Effect.tx(TxRef.set(ref, 2)) + yield* TxRef.set(ref, 2) yield* Deferred.succeed(resume, undefined) yield* Fiber.join(fiber) const recorded = yield* Ref.get(snapshots) From 84d3d0947042d9de6991b8807e2a9803124feca4 Mon Sep 17 00:00:00 2001 From: Maxim Khramtsov Date: Thu, 10 Sep 2026 12:53:40 +0200 Subject: [PATCH 6/6] fix lint --- packages/effect/test/Effectable.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/effect/test/Effectable.test.ts b/packages/effect/test/Effectable.test.ts index db5cdb586b4..86b81d46fdc 100644 --- a/packages/effect/test/Effectable.test.ts +++ b/packages/effect/test/Effectable.test.ts @@ -29,10 +29,7 @@ describe("Effectable", () => { return Effect.fail(this.value) } } - type asd = Effect.Success - type asd2 = Effect.Error - type asd3 = Effect.Services - + assert.deepStrictEqual(yield* Effect.exit(new FailingBox(1)), Exit.fail(1)) }))