diff --git a/CHANGELOG.md b/CHANGELOG.md index 313f889..f5302b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- Support the `transactionLimits` option on nested `ctx.runQuery` / + `ctx.runMutation` calls (Convex 1.41). The nested call is enforced against + its own limits, capped at the global transaction limits so they can only be + lowered, never raised. +- Bandwidth tracking now mirrors the database's nested-transaction layers: a + nested call's usage folds into its parent only when it commits, so the writes + of a rolled-back nested `ctx.runMutation` no longer count against the + transaction's limits. + ## 0.0.53 - Support scheduled functions correctly with or without fake timers, diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index f33e46a..84aa185 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -21,6 +21,7 @@ import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; import type * as messages from "../messages.js"; import type * as mutations from "../mutations.js"; +import type * as nestedLimits from "../nestedLimits.js"; import type * as pagination from "../pagination.js"; import type * as queries from "../queries.js"; import type * as returnsValidation from "../returnsValidation.js"; @@ -49,6 +50,7 @@ declare const fullApi: ApiFromModules<{ http: typeof http; messages: typeof messages; mutations: typeof mutations; + nestedLimits: typeof nestedLimits; pagination: typeof pagination; queries: typeof queries; returnsValidation: typeof returnsValidation; diff --git a/convex/nestedLimits.test.ts b/convex/nestedLimits.test.ts new file mode 100644 index 0000000..3c1d17e --- /dev/null +++ b/convex/nestedLimits.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "vitest"; +import { convexTest } from "../index"; +import { api } from "./_generated/api"; +import schema from "./schema"; + +const MiB = 1 << 20; + +async function seed(t: ReturnType, count: number) { + await t.run(async (ctx) => { + for (let i = 0; i < count; i++) { + await ctx.db.insert("messages", { author: "sarah", body: `msg${i}` }); + } + }); +} + +test("nested runQuery enforces its own documentsRead limit", async () => { + const t = convexTest({ schema }); + await seed(t, 10); + // Global limits are disabled, but the nested call explicitly asks for a + // tight limit, so it should be enforced. + await expect( + t.query(api.nestedLimits.parentReadWithLimits, { + transactionLimits: { documentsRead: 5 }, + }), + ).rejects.toThrow(/Scanned too many documents/); +}); + +test("nested runQuery under its limit succeeds", async () => { + const t = convexTest({ schema }); + await seed(t, 3); + const count = await t.query(api.nestedLimits.parentReadWithLimits, { + transactionLimits: { documentsRead: 5 }, + }); + expect(count).toBe(3); +}); + +test("nested runMutation enforces its own documentsWritten limit", async () => { + const t = convexTest({ schema }); + await expect( + t.mutation(api.nestedLimits.parentInsertWithLimits, { + count: 10, + transactionLimits: { documentsWritten: 3 }, + }), + ).rejects.toThrow(/Wrote too many documents/); +}); + +test("nested runMutation under its limit succeeds", async () => { + const t = convexTest({ schema }); + await t.mutation(api.nestedLimits.parentInsertWithLimits, { + count: 2, + transactionLimits: { documentsWritten: 5 }, + }); + const count = await t.query(api.nestedLimits.readAll, {}); + expect(count).toBe(2); +}); + +test("nested limits cannot raise above the global limit", async () => { + const t = convexTest({ + schema, + transactionLimits: { documentsRead: 5 }, + }); + await seed(t, 10); + // The nested call asks for documentsRead: 100000, but it is capped at the + // global limit of 5, so reading 10 docs still throws. + await expect( + t.query(api.nestedLimits.parentReadWithLimits, { + transactionLimits: { documentsRead: 100000 }, + }), + ).rejects.toThrow(/Scanned too many documents/); +}); + +test("global limit still applies when the nested limit is looser", async () => { + const t = convexTest({ + schema, + transactionLimits: { documentsRead: 5 }, + }); + await seed(t, 10); + // Nested limit (8) is looser, but the global limit (5) is the binding + // constraint and is enforced too. + await expect( + t.query(api.nestedLimits.parentReadWithLimits, { + transactionLimits: { documentsRead: 8 }, + }), + ).rejects.toThrow(/Scanned too many documents/); +}); + +test("nested limit does not leak to the parent transaction", async () => { + const t = convexTest({ + schema, + // Generous global limit so only the nested scope is restrictive. + transactionLimits: { documentsRead: 100, bytesRead: 16 * MiB }, + }); + await seed(t, 10); + // The nested query reads 2 docs under a tight limit of 3 (ok). After it + // returns, the parent reads all 10 docs itself. If the nested limit leaked, + // this would throw; under the global limit of 100 it succeeds. + const count = await t.query(api.nestedLimits.nestThenReadAll, { + nestedReadCount: 2, + transactionLimits: { documentsRead: 3 }, + }); + expect(count).toBe(10); +}); + +test("rolled-back nested writes are not counted against the transaction", async () => { + const t = convexTest({ schema }); + // The nested mutation writes 5 docs then throws, so its writes roll back. + // The parent swallows the error and reads back the transaction metrics: the + // rolled-back writes should not be counted. + const documentsWritten = await t.mutation( + api.nestedLimits.insertRollbackThenReportMetrics, + { count: 5 }, + ); + expect(documentsWritten).toBe(0); + // And nothing was actually persisted. + const count = await t.query(api.nestedLimits.readAll, {}); + expect(count).toBe(0); +}); + +test("rolled-back nested writes do not consume the global write limit", async () => { + const t = convexTest({ + schema, + transactionLimits: { documentsWritten: 5 }, + }); + // Nested mutation writes 4 docs then throws (rolled back). Afterwards the + // parent can still write 4 docs of its own: if the rolled-back writes had + // been counted, 4 + 4 would exceed the limit of 5. + await t.mutation(api.nestedLimits.parentInsertAfterRollback, { + rolledBackCount: 4, + keptCount: 4, + }); + const count = await t.query(api.nestedLimits.readAll, {}); + expect(count).toBe(4); +}); + +test("nested transactionLimits is optional", async () => { + const t = convexTest({ schema }); + await seed(t, 10); + const count = await t.query(api.nestedLimits.parentReadNoLimits, {}); + expect(count).toBe(10); +}); diff --git a/convex/nestedLimits.ts b/convex/nestedLimits.ts new file mode 100644 index 0000000..d69770f --- /dev/null +++ b/convex/nestedLimits.ts @@ -0,0 +1,139 @@ +import { v } from "convex/values"; +import { api } from "./_generated/api"; +import { mutation, query } from "./_generated/server"; + +const transactionLimitsValidator = v.object({ + bytesRead: v.optional(v.number()), + bytesWritten: v.optional(v.number()), + databaseQueries: v.optional(v.number()), + documentsRead: v.optional(v.number()), + documentsWritten: v.optional(v.number()), + functionsScheduled: v.optional(v.number()), + scheduledFunctionArgsBytes: v.optional(v.number()), +}); + +// Child query that reads every message. +export const readAll = query(async (ctx) => { + const docs = await ctx.db.query("messages").collect(); + return docs.length; +}); + +// Child query that reads exactly `n` messages. +export const readN = query({ + args: { n: v.number() }, + handler: async (ctx, { n }) => { + const docs = await ctx.db.query("messages").take(n); + return docs.length; + }, +}); + +// Child mutation that inserts `count` messages. +export const insertMany = mutation({ + args: { count: v.number() }, + handler: async (ctx, { count }) => { + for (let i = 0; i < count; i++) { + await ctx.db.insert("messages", { author: "child", body: `msg${i}` }); + } + }, +}); + +// Child mutation that inserts `count` messages and then throws, so all of its +// writes are rolled back. +export const insertManyThenThrow = mutation({ + args: { count: v.number() }, + handler: async (ctx, { count }) => { + for (let i = 0; i < count; i++) { + await ctx.db.insert("messages", { author: "child", body: `msg${i}` }); + } + throw new Error("nested boom"); + }, +}); + +// Parent mutation that calls a nested mutation which rolls back, swallows the +// error, then reports the transaction metrics. Used to verify the rolled-back +// nested writes are not counted against the transaction. +export const insertRollbackThenReportMetrics = mutation({ + args: { count: v.number() }, + handler: async (ctx, { count }) => { + try { + await ctx.runMutation(api.nestedLimits.insertManyThenThrow, { count }); + } catch { + // Swallow: the nested mutation's writes have been rolled back. + } + const metrics = await ctx.meta.getTransactionMetrics(); + return metrics.documentsWritten.used; + }, +}); + +// Parent mutation that runs a nested mutation which rolls back, then inserts +// `keptCount` messages of its own. Used to verify rolled-back nested writes +// don't consume the global write limit. +export const parentInsertAfterRollback = mutation({ + args: { rolledBackCount: v.number(), keptCount: v.number() }, + handler: async (ctx, { rolledBackCount, keptCount }) => { + try { + await ctx.runMutation(api.nestedLimits.insertManyThenThrow, { + count: rolledBackCount, + }); + } catch { + // Swallow: the nested mutation's writes have been rolled back. + } + for (let i = 0; i < keptCount; i++) { + await ctx.db.insert("messages", { author: "parent", body: `kept${i}` }); + } + }, +}); + +// Parent query that calls a nested query with custom transaction limits. +export const parentReadWithLimits = query({ + args: { transactionLimits: transactionLimitsValidator }, + handler: async (ctx, { transactionLimits }): Promise => { + return await ctx.runQuery( + api.nestedLimits.readAll, + {}, + { transactionLimits }, + ); + }, +}); + +// Parent query that calls a nested query without any custom limits. +export const parentReadNoLimits = query(async (ctx): Promise => { + return await ctx.runQuery(api.nestedLimits.readAll, {}); +}); + +// Parent mutation that calls a nested mutation with custom transaction limits. +export const parentInsertWithLimits = mutation({ + args: { + count: v.number(), + transactionLimits: transactionLimitsValidator, + }, + handler: async (ctx, { count, transactionLimits }) => { + await ctx.runMutation( + api.nestedLimits.insertMany, + { count }, + { transactionLimits }, + ); + }, +}); + +// Parent query that runs a tightly-limited nested query, then reads more on +// its own. Used to verify the nested scope's limit does not leak back to the +// parent transaction once the nested call returns. +export const nestThenReadAll = query({ + args: { + nestedReadCount: v.number(), + transactionLimits: transactionLimitsValidator, + }, + handler: async ( + ctx, + { nestedReadCount, transactionLimits }, + ): Promise => { + await ctx.runQuery( + api.nestedLimits.readN, + { n: nestedReadCount }, + { transactionLimits }, + ); + const docs = await ctx.db.query("messages").collect(); + return docs.length; + }, +}); diff --git a/index.ts b/index.ts index cf7b6af..b6b68f7 100644 --- a/index.ts +++ b/index.ts @@ -1498,6 +1498,7 @@ function asyncSyscallImpl() { reference, functionHandle, args: udfArgsJson, + transactionLimits, } = args; const udfArgs = jsonToConvex(udfArgsJson); const functionPath = getFunctionPathFromAddress({ @@ -1507,20 +1508,34 @@ function asyncSyscallImpl() { }); if (udfType === "query") { return JSON.stringify( - convexToJson(await withAuth().queryFromPath(functionPath, udfArgs)), + convexToJson( + await withAuth().queryFromPath( + functionPath, + udfArgs, + transactionLimits, + ), + ), ); } if (udfType === "snapshotQuery") { return JSON.stringify( convexToJson( - await withAuth().snapshotQueryFromPath(functionPath, udfArgs), + await withAuth().snapshotQueryFromPath( + functionPath, + udfArgs, + transactionLimits, + ), ), ); } if (udfType === "mutation") { return JSON.stringify( convexToJson( - await withAuth().mutationFromPath(functionPath, udfArgs), + await withAuth().mutationFromPath( + functionPath, + udfArgs, + transactionLimits, + ), ), ); } @@ -2116,14 +2131,22 @@ class TransactionManager { // can run mutations in parallel. private _waitOnCurrentFunction: Promise | null = null; private _markTransactionDone: (() => void) | null = null; - private _metricsTracker: TransactionMetricsTracker | null = null; + // Stack of metrics trackers. The first entry is the top-level (global) + // tracker; nested `ctx.runQuery` / `ctx.runMutation` calls with custom + // `transactionLimits` push additional, tighter trackers. Reads and writes + // are counted against every tracker in the stack, so each enforces its own + // limit simultaneously. + private _trackerStack: TransactionMetricsTracker[] = []; private _limitsConfig: Partial | boolean; constructor(limitsConfig: Partial | boolean = false) { this._limitsConfig = limitsConfig; } - async begin(isNested: boolean) { + async begin( + isNested: boolean, + transactionLimits?: Partial, + ) { // Take a lock only for the top-level of each transaction. // Nested transactions are not isolated so if you `Promise.all` on multiple // `ctx.runMutation` or `ctx.runQuery` calls, they won't be serialized. @@ -2138,7 +2161,15 @@ class TransactionManager { this._waitOnCurrentFunction = new Promise((resolve) => { this._markTransactionDone = resolve; }); - this._metricsTracker = new TransactionMetricsTracker(this._limitsConfig); + this._trackerStack = [new TransactionMetricsTracker(this._limitsConfig)]; + } else { + // Push a nested metrics layer alongside the database's nested + // transaction layer. It inherits the parent's accumulated usage (so the + // global limit still applies) plus any tighter `transactionLimits`. + const parent = this._trackerStack[this._trackerStack.length - 1]; + if (parent !== undefined) { + this._trackerStack.push(parent.createChild(transactionLimits)); + } } const convex = getConvexGlobal(); for (const component of Object.values(convex.components)) { @@ -2152,8 +2183,12 @@ class TransactionManager { return this._waitOnCurrentFunction !== null; } + // The tracker for the currently executing (innermost) function. Reads and + // writes are counted against it; nested layers fold back into their parent + // on commit/rollback. Returns null when not inside a transaction. getMetricsTracker(): TransactionMetricsTracker | null { - return this._metricsTracker; + const stack = this._trackerStack; + return stack.length === 0 ? null : stack[stack.length - 1]; } commit(isNested: boolean) { @@ -2161,6 +2196,13 @@ class TransactionManager { for (const component of Object.values(convex.components)) { component.db.commit(); } + if (isNested) { + const child = this._trackerStack.pop(); + const parent = this._trackerStack[this._trackerStack.length - 1]; + if (child !== undefined && parent !== undefined) { + child.commitInto(parent); + } + } this._endTransaction(isNested); } @@ -2169,6 +2211,13 @@ class TransactionManager { for (const component of Object.values(convex.components)) { component.db.rollbackWrites(); } + if (isNested) { + const child = this._trackerStack.pop(); + const parent = this._trackerStack[this._trackerStack.length - 1]; + if (child !== undefined && parent !== undefined) { + child.rollbackInto(parent); + } + } this._endTransaction(isNested); } @@ -2177,7 +2226,7 @@ class TransactionManager { throw new Error("Transaction not started"); } if (!isNested) { - this._metricsTracker = null; + this._trackerStack = []; this._waitOnCurrentFunction = null; this._markTransactionDone(); this._markTransactionDone = null; @@ -2360,6 +2409,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { extraCtx: any = {}, functionPath: FunctionPath, isNested: boolean, + transactionLimits?: Partial, ): Promise => { const m = mutationGeneric({ handler: (ctx: any, a: any) => { @@ -2382,7 +2432,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { depth: (parentCtx?.depth ?? 0) + 1, }; - await transactionManager.begin(isNested); + await transactionManager.begin(isNested, transactionLimits); try { const childLock = new NestedLock(); const invokeRaw = () => @@ -2414,6 +2464,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { args: any, functionPath: FunctionPath, isNested: boolean, + transactionLimits?: Partial, ): Promise => { const q = queryGeneric({ handler: (ctx: any, a: any) => { @@ -2432,7 +2483,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { depth: (parentCtx?.depth ?? 0) + 1, }; - await transactionManager.begin(isNested); + await transactionManager.begin(isNested, transactionLimits); try { const childLock = new NestedLock(); const invokeRaw = () => @@ -2507,7 +2558,11 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { }; const byTypeWithPath = { - queryFromPath: async (functionPath: FunctionPath, args: any) => { + queryFromPath: async ( + functionPath: FunctionPath, + args: any, + transactionLimits?: Partial, + ) => { const parentLock = nestedTxStorage.getStore() ?? null; const isNested = parentLock !== null; if (parentLock) { @@ -2522,6 +2577,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { args, resolved.functionPath, isNested, + transactionLimits, ); validateReturnValue( resolved.returns, @@ -2537,7 +2593,11 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { } }, - snapshotQueryFromPath: async (functionPath: FunctionPath, args: any) => { + snapshotQueryFromPath: async ( + functionPath: FunctionPath, + args: any, + transactionLimits?: Partial, + ) => { const parentLock = nestedTxStorage.getStore() ?? null; if (parentLock) { await parentLock.acquire(); @@ -2556,6 +2616,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { // isNested=true to avoid re-acquiring the top-level lock // (we're inside a mutation that already holds it). /* isNested */ true, + transactionLimits, ); validateReturnValue( resolved.returns, @@ -2575,6 +2636,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { mutationFromPath: async ( functionPath: FunctionPath, args: any, + transactionLimits?: Partial, ): Promise => { const parentLock = nestedTxStorage.getStore() ?? null; const isNested = parentLock !== null; @@ -2591,6 +2653,7 @@ function withAuth(auth: AuthFake = authStorage.getStore() ?? new AuthFake()) { {}, resolved.functionPath, isNested, + transactionLimits, ); validateReturnValue( resolved.returns, diff --git a/package-lock.json b/package-lock.json index 81322b4..a6d528e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@vitest/coverage-v8": "1.6.1", - "convex": "1.38.0", + "convex": "1.41.0", "eslint": "9.39.4", "globals": "17.5.0", "pkg-pr-new": "0.0.66", @@ -2210,15 +2210,15 @@ "license": "MIT" }, "node_modules/convex": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/convex/-/convex-1.38.0.tgz", - "integrity": "sha512-122AC6y5lUS7mr39cluLw9+TOtRX5d/XxeivHhHObs/NTXoVvOnIgDzexVcxaz6Rk0oLFSoydSR1rDCltEz/0A==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.41.0.tgz", + "integrity": "sha512-euxVf6yfpB7/VGKOobkLgjpbJidsUgW+b0ezonEyCUPqlpHFwR4/yIiI1hjjErzraiw91GxrtxpXQClMLNqU+w==", "dev": true, "license": "Apache-2.0", "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", - "ws": "8.18.0" + "ws": "8.20.1" }, "bin": { "convex": "bin/main.js" @@ -4817,9 +4817,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { @@ -6247,14 +6247,14 @@ "dev": true }, "convex": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/convex/-/convex-1.38.0.tgz", - "integrity": "sha512-122AC6y5lUS7mr39cluLw9+TOtRX5d/XxeivHhHObs/NTXoVvOnIgDzexVcxaz6Rk0oLFSoydSR1rDCltEz/0A==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.41.0.tgz", + "integrity": "sha512-euxVf6yfpB7/VGKOobkLgjpbJidsUgW+b0ezonEyCUPqlpHFwR4/yIiI1hjjErzraiw91GxrtxpXQClMLNqU+w==", "dev": true, "requires": { "esbuild": "0.27.0", "prettier": "^3.0.0", - "ws": "8.18.0" + "ws": "8.20.1" } }, "cross-spawn": { @@ -7861,9 +7861,9 @@ "dev": true }, "ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index 85189df..be0f21d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@vitest/coverage-v8": "1.6.1", - "convex": "1.38.0", + "convex": "1.41.0", "eslint": "9.39.4", "globals": "17.5.0", "pkg-pr-new": "0.0.66", diff --git a/transactionMetrics.ts b/transactionMetrics.ts index bbeb68a..280dee6 100644 --- a/transactionMetrics.ts +++ b/transactionMetrics.ts @@ -8,6 +8,15 @@ export type TransactionMetrics = { scheduledFunctionArgsBytes: number; }; +// Metrics that track reads. On rollback these are still folded into the +// parent (the reads happened), unlike the write metrics whose effects are +// undone. +const READ_METRIC_KEYS: (keyof TransactionMetrics)[] = [ + "bytesRead", + "documentsRead", + "databaseQueries", +]; + const MiB = 1 << 20; const DEFAULT_TRANSACTION_LIMITS: TransactionMetrics = { @@ -123,6 +132,56 @@ export class TransactionMetricsTracker { } } + /** + * Create a tracker for a nested `ctx.runQuery` / `ctx.runMutation` call, + * mirroring how the database pushes a nested transaction layer. + * + * The child starts from this (parent) tracker's accumulated usage, so the + * global limit is enforced against the whole transaction's cumulative + * consumption. When `transactionLimits` is provided, each field additionally + * caps the nested call's own consumption; the cap is taken against the + * global limit so it can only lower the budget, never raise it. + * + * Fold the child back into its parent with `commitInto` (success) or + * `rollbackInto` (rolled back) once the nested call returns. + */ + createChild( + transactionLimits?: Partial, + ): TransactionMetricsTracker { + const child = new TransactionMetricsTracker(false); + child._metrics = { ...this._metrics }; + child._limits = { ...this._limits }; + child._enforceLimits = + this._enforceLimits || transactionLimits !== undefined; + if (transactionLimits !== undefined) { + for (const key of Object.keys( + this._limits, + ) as (keyof TransactionMetrics)[]) { + const requested = transactionLimits[key]; + if (requested !== undefined) { + child._limits[key] = Math.min( + this._limits[key], + this._metrics[key] + requested, + ); + } + } + } + return child; + } + + // Fold a committed child's cumulative usage back into this parent. + commitInto(parent: TransactionMetricsTracker) { + parent._metrics = { ...this._metrics }; + } + + // Fold a rolled-back child back into this parent. Only reads are kept; the + // child's writes were undone, so they don't count against the transaction. + rollbackInto(parent: TransactionMetricsTracker) { + for (const key of READ_METRIC_KEYS) { + parent._metrics[key] = this._metrics[key]; + } + } + getTransactionMetrics() { return { bytesRead: {