diff --git a/src/Bottleneck.ts b/src/Bottleneck.ts index 226112b..602a9c5 100644 --- a/src/Bottleneck.ts +++ b/src/Bottleneck.ts @@ -13,7 +13,6 @@ import type { StrategyConstants, } from "./types"; import pkg from "../package.json" with { type: "json" }; -import { captureScheduleLocation, withScheduleCutoff } from "./async-context"; import Batcher from "./Batcher"; import BottleneckError from "./BottleneckError"; import IORedisConnection from "./cluster/IORedisConnection"; @@ -26,6 +25,7 @@ import LocalDatastore from "./LocalDatastore"; import { load, overwrite } from "./parser"; import Queues from "./Queues"; import randomIndex from "./random-index"; +import { captureScheduleLocation, withScheduleCutoff } from "./schedule-stack"; import States from "./States"; import Sync from "./Sync"; @@ -555,6 +555,7 @@ class Bottleneck { captureScheduleLocation( this.captureScheduleStack ? (this.scheduleStackLabel ?? this.id) : null, Bottleneck.prototype.schedule, + this.schedule, ), ); this._receive(job); diff --git a/src/Job.ts b/src/Job.ts index 97b9c99..5814315 100644 --- a/src/Job.ts +++ b/src/Job.ts @@ -3,17 +3,11 @@ import type Bottleneck from "./Bottleneck"; import type Events from "./Events"; import type States from "./States"; import type { EventInfo, EventInfoRetryable, JobDefaults, ResolvedJobOptions } from "./types"; -import { - attachScheduleStack, - bindTask, - captureAsyncResource, - forwardChain, - runWithAsyncResource, - type ScheduleStackCapture, -} from "./async-context"; +import { bindTask, captureAsyncResource, runWithAsyncResource } from "./async-context"; import BottleneckError from "./BottleneckError"; import { load } from "./parser"; import randomIndex from "./random-index"; +import { attachScheduleStack, forwardChain, type ScheduleStackCapture } from "./schedule-stack"; const NUM_PRIORITIES = 10; const DEFAULT_PRIORITY = 5; diff --git a/src/async-context.ts b/src/async-context.ts index 150322b..aa85e56 100644 --- a/src/async-context.ts +++ b/src/async-context.ts @@ -88,174 +88,3 @@ export function setDetachedTimeout( ): ReturnType { return runWithAsyncResource(detachedResource, () => setTimeout(callback, ms)); } - -/** - * Strip the "Error" headline from a captured stack, leaving only the frames. - */ -export function cleanScheduleStack(raw: string | undefined): string | undefined { - if (typeof raw !== "string") { - return undefined; - } - const lines = raw.split("\n"); - if (lines.length > 0 && !/^\s*at\s/.test(lines[0] ?? "")) { - lines.shift(); - } - while (lines.length > 0 && (lines[0] ?? "").trim() === "") { - lines.shift(); - } - if (lines.length === 0) { - return undefined; - } - return lines.join("\n"); -} - -/** - * Where a job was scheduled. `holder.stack` is read only when a task fails, - * so `Error.prepareStackTrace` (source maps) stays off the schedule() path. - */ -export type ScheduleStackCapture = { - holder?: { stack?: string }; - label: string; - /** A chained limiter's job: no caller frames, rendered as a "via" label. */ - chained?: boolean; -}; - -type ScheduleCutoff = (...args: never[]) => unknown; - -// Set around the synchronous call that builds a job. Library frames are cut -// by function identity, not file path, so bundled app code is never mistaken -// for library frames. -let scheduleCutoff: ScheduleCutoff | undefined; -let forwardingChain = false; - -/** Cut stacks of jobs scheduled in `cb` at `fn`, an entry point like wrap(). */ -export function withScheduleCutoff(fn: ScheduleCutoff, cb: () => T): T { - const previous = scheduleCutoff; - scheduleCutoff = fn; - try { - return cb(); - } finally { - scheduleCutoff = previous; - } -} - -/** Run `cb`, marking the job it schedules as a chained forward. */ -export function forwardChain(cb: () => T): T { - const previous = forwardingChain; - forwardingChain = true; - try { - return cb(); - } finally { - forwardingChain = previous; - } -} - -/** Capture where a job is scheduled; undefined when `label` is null (disabled). */ -export function captureScheduleLocation( - label: string | null, - defaultCutoff: ScheduleCutoff, -): ScheduleStackCapture | undefined { - const chained = forwardingChain; - // Consume the flag so a listener scheduling during the forward isn't chained. - forwardingChain = false; - if (label == null) { - return undefined; - } - if (chained) { - return { label, chained: true }; - } - try { - const holder: { stack?: string } = {}; - if (typeof Error.captureStackTrace === "function") { - Error.captureStackTrace(holder, scheduleCutoff ?? defaultCutoff); - } else { - holder.stack = new Error().stack; - } - return { holder, label }; - } catch { - return undefined; - } -} - -const SCHEDULE_MARKER_PREFIX = "From previous Bottleneck.schedule location"; -const MAX_SCHEDULE_SECTIONS = 3; - -// Per-error state. `seen` skips retries (same capture); `via` holds chained -// labels for the next section; `lastStart`/`written` let the last section be -// replaced in place once capped. -type Annotation = { - seen: Set; - via: string[]; - sections: number; - omitted: number; - lastStart: number; - written: string; -}; - -const annotations = new WeakMap(); - -/** - * Append where the job was scheduled to a task failure's stack. Nested - * limiters each add a section, innermost first; past MAX_SCHEDULE_SECTIONS - * the last section is replaced, so the outermost (the app's call site) is - * always kept. Returns the original error, mutated in place when possible; - * non-Error rejections pass through untouched. - */ -export function attachScheduleStack(error: T, capture: ScheduleStackCapture | undefined): T { - if (capture == null) { - return error; - } - if (typeof error !== "object" || error === null) { - return error; - } - const target = error as { stack?: unknown }; - if (typeof target.stack !== "string") { - return error; - } - let state = annotations.get(error); - if (state?.seen.has(capture)) { - return error; - } - if (state == null) { - state = { seen: new Set(), via: [], sections: 0, omitted: 0, lastStart: 0, written: "" }; - annotations.set(error, state); - } - state.seen.add(capture); - if (capture.chained) { - // Inner limiters fail first: prepend so the list reads outer → inner. - state.via.unshift(capture.label); - return error; - } - const replacing = state.sections >= MAX_SCHEDULE_SECTIONS; - if (replacing && target.stack !== state.written) { - // Edited elsewhere since our last write; slicing could cut that text. - return error; - } - try { - const frames = cleanScheduleStack(capture.holder?.stack); - if (frames == null) { - return error; - } - const via = state.via.length > 0 ? `, via ${state.via.join(", ")}` : ""; - let section = `${SCHEDULE_MARKER_PREFIX} (${capture.label}${via}):\n${frames}`; - let base = target.stack; - if (replacing) { - const omitted = state.omitted + 1; - section = ` ... ${omitted} nested schedule location${omitted === 1 ? "" : "s"} omitted\n${section}`; - base = base.slice(0, state.lastStart); - } - const stack = `${base}\n${section}`; - target.stack = stack; - state.via = []; - state.lastStart = base.length; - state.written = stack; - if (replacing) { - state.omitted++; - } else { - state.sections++; - } - } catch { - // Throwing prepareStackTrace or read-only stack: leave it untouched. - } - return error; -} diff --git a/src/schedule-stack.ts b/src/schedule-stack.ts new file mode 100644 index 0000000..e7c7b9b --- /dev/null +++ b/src/schedule-stack.ts @@ -0,0 +1,190 @@ +/** + * Strip the "Error" headline from a captured stack, leaving only the frames. + */ +function cleanScheduleStack(raw: string | undefined): string | undefined { + if (typeof raw !== "string") { + return undefined; + } + const lines = raw.split("\n"); + if (lines.length > 0 && !/^\s*at\s/.test(lines[0] ?? "")) { + lines.shift(); + } + while (lines.length > 0 && (lines[0] ?? "").trim() === "") { + lines.shift(); + } + if (lines.length === 0) { + return undefined; + } + return lines.join("\n"); +} + +/** + * Where a job was scheduled. `holder.stack` is read only when a task fails, + * so `Error.prepareStackTrace` (source maps) stays off the schedule() path. + */ +export type ScheduleStackCapture = { + holder?: { stack?: string }; + /** Cut at the base schedule(), for when an override cutoff captured nothing. */ + fallback?: { stack?: string }; + label: string; + /** A chained limiter's job: no caller frames, rendered as a "via" label. */ + chained?: boolean; +}; + +type ScheduleCutoff = (...args: never[]) => unknown; + +// Set around the synchronous call that builds a job. Library frames are cut +// by function identity, not file path, so bundled app code is never mistaken +// for library frames. +let scheduleCutoff: ScheduleCutoff | undefined; +let forwardingChain = false; + +/** Cut stacks of jobs scheduled in `cb` at `fn`, an entry point like wrap(). */ +export function withScheduleCutoff(fn: ScheduleCutoff, cb: () => T): T { + const previous = scheduleCutoff; + scheduleCutoff = fn; + try { + return cb(); + } finally { + scheduleCutoff = previous; + } +} + +/** Run `cb`, marking the job it schedules as a chained forward. */ +export function forwardChain(cb: () => T): T { + const previous = forwardingChain; + forwardingChain = true; + try { + return cb(); + } finally { + forwardingChain = previous; + } +} + +/** + * Capture where a job is scheduled; undefined when `label` is null (disabled). + * `base` is the library's schedule(); `override` is the limiter's own + * `schedule`, which differs when a subclass or instance replaces it. + */ +export function captureScheduleLocation( + label: string | null, + base: ScheduleCutoff, + override: ScheduleCutoff, +): ScheduleStackCapture | undefined { + const chained = forwardingChain; + // Consume the flag so a listener scheduling during the forward isn't chained. + forwardingChain = false; + if (label == null) { + return undefined; + } + if (chained) { + return { label, chained: true }; + } + try { + const holder: { stack?: string } = {}; + if (typeof Error.captureStackTrace !== "function") { + holder.stack = new Error().stack; + return { holder, label }; + } + // Cutting at an override drops its wrapper frames too. V8 doesn't cut at + // a bound function: the capture would start inside bottleneck and isn't + // empty, so the fallback can't catch it. Bound overrides use `base`. + const useOverride = + scheduleCutoff == null && override !== base && !override.name.startsWith("bound "); + Error.captureStackTrace(holder, scheduleCutoff ?? (useOverride ? override : base)); + if (!useOverride) { + return { holder, label }; + } + // An override that isn't on the stack (e.g. `base` called directly) + // captures nothing. + const fallback: { stack?: string } = {}; + Error.captureStackTrace(fallback, base); + return { holder, fallback, label }; + } catch { + return undefined; + } +} + +const SCHEDULE_MARKER_PREFIX = "From previous Bottleneck.schedule location"; +const MAX_SCHEDULE_SECTIONS = 3; + +// Per-error state. `seen` skips retries (same capture); `via` holds chained +// labels for the next section; `lastStart`/`written` let the last section be +// replaced in place once capped. +type Annotation = { + seen: Set; + via: string[]; + sections: number; + omitted: number; + lastStart: number; + written: string; +}; + +const annotations = new WeakMap(); + +/** + * Append where the job was scheduled to a task failure's stack. Nested + * limiters each add a section, innermost first; past MAX_SCHEDULE_SECTIONS + * the last section is replaced, so the outermost (the app's call site) is + * always kept. Returns the original error, mutated in place when possible; + * non-Error rejections pass through untouched. + */ +export function attachScheduleStack(error: T, capture: ScheduleStackCapture | undefined): T { + if (capture == null) { + return error; + } + if (typeof error !== "object" || error === null) { + return error; + } + const target = error as { stack?: unknown }; + if (typeof target.stack !== "string") { + return error; + } + let state = annotations.get(error); + if (state?.seen.has(capture)) { + return error; + } + if (state == null) { + state = { seen: new Set(), via: [], sections: 0, omitted: 0, lastStart: 0, written: "" }; + annotations.set(error, state); + } + state.seen.add(capture); + if (capture.chained) { + // Inner limiters fail first: prepend so the list reads outer → inner. + state.via.unshift(capture.label); + return error; + } + const replacing = state.sections >= MAX_SCHEDULE_SECTIONS; + if (replacing && target.stack !== state.written) { + // Edited elsewhere since our last write; slicing could cut that text. + return error; + } + try { + const frames = + cleanScheduleStack(capture.holder?.stack) ?? cleanScheduleStack(capture.fallback?.stack); + if (frames == null) { + return error; + } + const via = state.via.length > 0 ? `, via ${state.via.join(", ")}` : ""; + let section = `${SCHEDULE_MARKER_PREFIX} (${capture.label}${via}):\n${frames}`; + let base = target.stack; + if (replacing) { + const omitted = state.omitted + 1; + section = ` ... ${omitted} nested schedule location${omitted === 1 ? "" : "s"} omitted\n${section}`; + base = base.slice(0, state.lastStart); + } + const stack = `${base}\n${section}`; + target.stack = stack; + state.via = []; + state.lastStart = base.length; + state.written = stack; + if (replacing) { + state.omitted++; + } else { + state.sections++; + } + } catch { + // Throwing prepareStackTrace or read-only stack: leave it untouched. + } + return error; +} diff --git a/test/async-context.test.ts b/test/async-context.test.ts index 93422fa..0db48db 100644 --- a/test/async-context.test.ts +++ b/test/async-context.test.ts @@ -1,32 +1,11 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { describe, expect, vi } from "vitest"; -import type Bottleneck from "../src/Bottleneck"; import { runDetached, setDetachedTimeout } from "../src/async-context"; import { useFakeClock } from "./helpers/clock"; import { deferred, enqueued, test } from "./helpers/test-api"; useFakeClock(); -function innerTask(): never { - throw new Error("boom-inner"); -} - -const MARKER = "From previous Bottleneck.schedule location"; - -/** Schedule-location sections appended to an error's stack, marker included. */ -function scheduleSections(error: unknown): string[] { - return ((error as Error).stack ?? "") - .split(MARKER) - .slice(1) - .map((section) => MARKER + section); -} - -/** Frames the stack cutoff must remove: schedule(), wrap(), chain forwarding. */ -function expectNoLibraryFrames(section: string | undefined): void { - expect(section).toBeDefined(); - expect(section).not.toMatch(/src[/\\](Bottleneck|Job)\.ts/); -} - describe("Async context", () => { test("preserves AsyncLocalStorage for jobs that wait in the queue", async ({ makeLimiter }) => { const als = new AsyncLocalStorage(); @@ -137,240 +116,4 @@ describe("Async context", () => { } expect(await pending!).toBeUndefined(); }); - - test("rejection stack includes both the task and the schedule call site", async ({ - makeLimiter, - }) => { - const limiter = makeLimiter({ maxConcurrent: 1 }); - - function outerScheduleTask() { - return limiter.schedule(() => innerTask()); - } - - const error = await outerScheduleTask().catch((e: unknown) => e); - expect(error).toBeInstanceOf(Error); - const stack = (error as Error).stack ?? ""; - expect(stack).toContain("boom-inner"); - expect(stack).toContain("innerTask"); - const [section] = scheduleSections(error); - expect(section).toContain("outerScheduleTask"); - expectNoLibraryFrames(section); - }); - - test("retrying the same error instance augments the stack only once", async ({ makeLimiter }) => { - const limiter = makeLimiter({ maxConcurrent: 1 }); - const failure = new Error("boom-retry"); - let attempts = 0; - let failures = 0; - limiter.on("failed", () => { - failures++; - if (attempts < 2) { - return 10; - } - return undefined; - }); - - await expect( - limiter.schedule(() => { - attempts++; - throw failure; - }), - ).rejects.toBe(failure); - expect(attempts).toBe(2); - expect(failures).toBe(2); - expect(scheduleSections(failure)).toHaveLength(1); - }); - - test("schedule() does not format stacks until a task fails", async ({ makeLimiter }) => { - const limiter = makeLimiter({ maxConcurrent: 5 }); - let formats = 0; - const original = Error.prepareStackTrace; - Error.prepareStackTrace = ((...args: unknown[]) => { - formats++; - return (original as (...a: unknown[]) => unknown)?.(...args); - }) as typeof Error.prepareStackTrace; - try { - await Promise.all( - Array.from({ length: 20 }, (_, i) => limiter.schedule(() => Promise.resolve(i))), - ); - } finally { - Error.prepareStackTrace = original; - } - expect(formats).toBe(0); - }); - - test("a chain renders as one section listing every chained limiter", async ({ makeLimiter }) => { - // The head limiter uses the project's datastore; the chained ones are local. - const limiters = Array.from( - { length: 5 }, - (_, i) => - makeLimiter( - i === 0 ? { id: "chain-0" } : { id: `chain-${i}`, datastore: "local" }, - ) as Bottleneck, - ); - for (let i = 0; i < limiters.length - 1; i++) { - limiters[i]!.chain(limiters[i + 1]!); - } - - function scheduleOnChain(): Promise { - return limiters[0]!.schedule(() => { - throw new Error("boom-chain"); - }); - } - - const error = await scheduleOnChain().catch((e: unknown) => e); - expect((error as Error).stack).toContain("boom-chain"); - const sections = scheduleSections(error); - expect(sections).toHaveLength(1); - // Redis projects prefix limiter ids per test; match the stable suffixes. - expect(sections[0]).toMatch(/chain-0, via \S*chain-1, \S*chain-2, \S*chain-3, \S*chain-4\):/); - expect(sections[0]).toContain("scheduleOnChain"); - expectNoLibraryFrames(sections[0]); - }); - - test("nested schedule locations keep the innermost two and the outermost", async ({ - makeLimiter, - }) => { - const limiters = Array.from( - { length: 5 }, - (_, i) => - makeLimiter({ - id: `nest-cap-${i}`, - datastore: "local", - }) as Bottleneck, - ); - - function scheduleAt(i: number): Promise { - return limiters[i]!.schedule(() => { - if (i === limiters.length - 1) { - throw new Error("boom-deep-nest"); - } - return scheduleAt(i + 1); - }); - } - function startNestedSchedules(): Promise { - return scheduleAt(0); - } - - const error = await startNestedSchedules().catch((e: unknown) => e); - const stack = (error as Error).stack ?? ""; - expect(stack).toContain("boom-deep-nest"); - const sections = scheduleSections(error); - // Redis projects prefix limiter ids per test; match the stable suffixes. - expect(sections.map((section) => /nest-cap-\d/.exec(section)?.[0])).toEqual([ - "nest-cap-4", - "nest-cap-3", - "nest-cap-0", - ]); - expect(stack).toContain("... 2 nested schedule locations omitted\nFrom previous"); - // Only the outermost section reaches the app's original call site. - expect(sections[2]).toContain("startNestedSchedules"); - expect(sections[0]).not.toContain("startNestedSchedules"); - }); - - test("wrap() and withOptions() capture the wrapped function's caller", async ({ - makeLimiter, - }) => { - const limiter = makeLimiter({ id: "wrap-stack" }); - const wrapped = limiter.wrap((): never => { - throw new Error("boom-wrap"); - }); - - function callWrapped(): Promise { - return wrapped(); - } - function callWithOptions(): Promise { - return wrapped.withOptions({}); - } - - for (const [caller, call] of [ - ["callWrapped", callWrapped], - ["callWithOptions", callWithOptions], - ] as const) { - const sections = scheduleSections(await call().catch((e: unknown) => e)); - expect(sections).toHaveLength(1); - expect(sections[0]).toContain(caller); - expectNoLibraryFrames(sections[0]); - } - }); - - test("captureScheduleStack:false skips the schedule location", async ({ makeLimiter }) => { - const limiter = makeLimiter({ captureScheduleStack: false }); - const error = await limiter - .schedule(() => { - throw new Error("boom-no-stack"); - }) - .catch((e: unknown) => e); - expect((error as Error).stack).toContain("boom-no-stack"); - expect(scheduleSections(error)).toHaveLength(0); - }); - - test("an instance-patched schedule wrapper still captures the caller", async ({ - makeLimiter, - }) => { - const limiter = makeLimiter({ id: "patched-schedule-stack" }); - const originalSchedule = limiter.schedule.bind(limiter); - (limiter as { schedule: unknown }).schedule = function (...args: unknown[]) { - return (originalSchedule as (...a: never[]) => unknown)(...(args as never[])); - }; - - function callerScheduleTask(): Promise { - const schedule = limiter.schedule as (...a: any[]) => Promise; - return schedule(() => { - throw new Error("boom-wrapper"); - }); - } - - const error = await callerScheduleTask().catch((e: unknown) => e); - const [section] = scheduleSections(error); - expect(section).toContain("callerScheduleTask"); - expectNoLibraryFrames(section); - }); - - test("a schedule nested in a task body appends both locations", async ({ makeLimiter }) => { - const inner = makeLimiter({ id: "task-body-inner", datastore: "local" }); - const outer = makeLimiter({ id: "task-body-outer", datastore: "local" }); - - function scheduleFromTaskBody(): Promise { - return outer.schedule(() => - inner.schedule(() => { - throw new Error("boom-task-body"); - }), - ) as Promise; - } - - const error = await scheduleFromTaskBody().catch((e: unknown) => e); - const stack = (error as Error).stack ?? ""; - expect(stack).toContain("boom-task-body"); - // Redis projects prefix limiter ids per test; match the stable suffix. - expect(stack).toContain("task-body-inner):"); - expect(stack).toContain("task-body-outer):"); - expect(stack).toContain("scheduleFromTaskBody"); - }); - - test("an explicit scheduleStackLabel is used for the marker", async ({ makeLimiter }) => { - const limiter = makeLimiter({ id: "pii-check-123", scheduleStackLabel: "check" }); - const error = await limiter - .schedule(() => { - throw new Error("boom-label"); - }) - .catch((e: unknown) => e); - const stack = (error as Error).stack ?? ""; - expect(stack).toContain("From previous Bottleneck.schedule location (check):"); - expect(stack).not.toContain("pii-check-123"); - }); - - test("a group-level scheduleStackLabel labels child markers", async ({ makeGroup }) => { - const group = makeGroup({ id: "tm-create-update", scheduleStackLabel: "payroll" }); - const limiter = group.key("acme-email-jane@acme.com"); - - const error = await limiter - .schedule(() => { - throw new Error("boom-group-label"); - }) - .catch((e: unknown) => e); - const stack = (error as Error).stack ?? ""; - expect(stack).toContain("From previous Bottleneck.schedule location (payroll):"); - expect(stack).not.toContain("jane@acme.com"); - }); }); diff --git a/test/schedule-stack.test.ts b/test/schedule-stack.test.ts new file mode 100644 index 0000000..12e6cea --- /dev/null +++ b/test/schedule-stack.test.ts @@ -0,0 +1,321 @@ +import { describe, expect } from "vitest"; +import Bottleneck from "../src/Bottleneck"; +import { useFakeClock } from "./helpers/clock"; +import { test } from "./helpers/test-api"; + +useFakeClock(); + +function innerTask(): never { + throw new Error("boom-inner"); +} + +const MARKER = "From previous Bottleneck.schedule location"; + +/** Schedule-location sections appended to an error's stack, marker included. */ +function scheduleSections(error: unknown): string[] { + return ((error as Error).stack ?? "") + .split(MARKER) + .slice(1) + .map((section) => MARKER + section); +} + +/** Frames the stack cutoff must remove: schedule(), wrap(), chain forwarding. */ +function expectNoLibraryFrames(section: string | undefined): void { + expect(section).toBeDefined(); + expect(section).not.toMatch(/src[/\\](Bottleneck|Job)\.ts/); +} + +describe("Schedule stacks", () => { + test("rejection stack includes both the task and the schedule call site", async ({ + makeLimiter, + }) => { + const limiter = makeLimiter({ maxConcurrent: 1 }); + + function outerScheduleTask() { + return limiter.schedule(() => innerTask()); + } + + const error = await outerScheduleTask().catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + const stack = (error as Error).stack ?? ""; + expect(stack).toContain("boom-inner"); + expect(stack).toContain("innerTask"); + const [section] = scheduleSections(error); + expect(section).toContain("outerScheduleTask"); + expectNoLibraryFrames(section); + }); + + test("retrying the same error instance augments the stack only once", async ({ makeLimiter }) => { + const limiter = makeLimiter({ maxConcurrent: 1 }); + const failure = new Error("boom-retry"); + let attempts = 0; + let failures = 0; + limiter.on("failed", () => { + failures++; + if (attempts < 2) { + return 10; + } + return undefined; + }); + + await expect( + limiter.schedule(() => { + attempts++; + throw failure; + }), + ).rejects.toBe(failure); + expect(attempts).toBe(2); + expect(failures).toBe(2); + expect(scheduleSections(failure)).toHaveLength(1); + }); + + test("schedule() does not format stacks until a task fails", async ({ makeLimiter }) => { + const limiter = makeLimiter({ maxConcurrent: 5 }); + let formats = 0; + const original = Error.prepareStackTrace; + Error.prepareStackTrace = ((...args: unknown[]) => { + formats++; + return (original as (...a: unknown[]) => unknown)?.(...args); + }) as typeof Error.prepareStackTrace; + try { + await Promise.all( + Array.from({ length: 20 }, (_, i) => limiter.schedule(() => Promise.resolve(i))), + ); + } finally { + Error.prepareStackTrace = original; + } + expect(formats).toBe(0); + }); + + test("a chain renders as one section listing every chained limiter", async ({ makeLimiter }) => { + // The head limiter uses the project's datastore; the chained ones are local. + const limiters = Array.from( + { length: 5 }, + (_, i) => + makeLimiter( + i === 0 ? { id: "chain-0" } : { id: `chain-${i}`, datastore: "local" }, + ) as Bottleneck, + ); + for (let i = 0; i < limiters.length - 1; i++) { + limiters[i]!.chain(limiters[i + 1]!); + } + + function scheduleOnChain(): Promise { + return limiters[0]!.schedule(() => { + throw new Error("boom-chain"); + }); + } + + const error = await scheduleOnChain().catch((e: unknown) => e); + expect((error as Error).stack).toContain("boom-chain"); + const sections = scheduleSections(error); + expect(sections).toHaveLength(1); + // Redis projects prefix limiter ids per test; match the stable suffixes. + expect(sections[0]).toMatch(/chain-0, via \S*chain-1, \S*chain-2, \S*chain-3, \S*chain-4\):/); + expect(sections[0]).toContain("scheduleOnChain"); + expectNoLibraryFrames(sections[0]); + }); + + test("nested schedule locations keep the innermost two and the outermost", async ({ + makeLimiter, + }) => { + const limiters = Array.from( + { length: 5 }, + (_, i) => + makeLimiter({ + id: `nest-cap-${i}`, + datastore: "local", + }) as Bottleneck, + ); + + function scheduleAt(i: number): Promise { + return limiters[i]!.schedule(() => { + if (i === limiters.length - 1) { + throw new Error("boom-deep-nest"); + } + return scheduleAt(i + 1); + }); + } + function startNestedSchedules(): Promise { + return scheduleAt(0); + } + + const error = await startNestedSchedules().catch((e: unknown) => e); + const stack = (error as Error).stack ?? ""; + expect(stack).toContain("boom-deep-nest"); + const sections = scheduleSections(error); + // Redis projects prefix limiter ids per test; match the stable suffixes. + expect(sections.map((section) => /nest-cap-\d/.exec(section)?.[0])).toEqual([ + "nest-cap-4", + "nest-cap-3", + "nest-cap-0", + ]); + expect(stack).toContain("... 2 nested schedule locations omitted\nFrom previous"); + // Only the outermost section reaches the app's original call site. + expect(sections[2]).toContain("startNestedSchedules"); + expect(sections[0]).not.toContain("startNestedSchedules"); + }); + + test("wrap() and withOptions() capture the wrapped function's caller", async ({ + makeLimiter, + }) => { + const limiter = makeLimiter({ id: "wrap-stack" }); + const wrapped = limiter.wrap((): never => { + throw new Error("boom-wrap"); + }); + + function callWrapped(): Promise { + return wrapped(); + } + function callWithOptions(): Promise { + return wrapped.withOptions({}); + } + + for (const [caller, call] of [ + ["callWrapped", callWrapped], + ["callWithOptions", callWithOptions], + ] as const) { + const sections = scheduleSections(await call().catch((e: unknown) => e)); + expect(sections).toHaveLength(1); + expect(sections[0]).toContain(caller); + expectNoLibraryFrames(sections[0]); + } + }); + + test("captureScheduleStack:false skips the schedule location", async ({ makeLimiter }) => { + const limiter = makeLimiter({ captureScheduleStack: false }); + const error = await limiter + .schedule(() => { + throw new Error("boom-no-stack"); + }) + .catch((e: unknown) => e); + expect((error as Error).stack).toContain("boom-no-stack"); + expect(scheduleSections(error)).toHaveLength(0); + }); + + test("schedule overrides cut their own wrapper frames", async ({ track }) => { + class SubclassLimiter extends Bottleneck { + override schedule(...args: unknown[]): Promise { + return this.scheduleWithOpts(args); + } + scheduleWithOpts(args: unknown[]): Promise { + return (super.schedule as (...a: unknown[]) => Promise)(...args); + } + } + // Async overrides run synchronously until their first await, so the + // override is still on the stack when the base schedule() captures. + class AsyncSubclassLimiter extends Bottleneck { + override async schedule(...args: unknown[]): Promise { + return this.scheduleWithOpts(args); + } + scheduleWithOpts(args: unknown[]): Promise { + return (super.schedule as (...a: unknown[]) => Promise)(...args); + } + } + const arrowPatched = track(new Bottleneck({ id: "override-arrow", datastore: "local" })); + const original = Bottleneck.prototype.schedule as (...a: unknown[]) => Promise; + function scheduleWithOpts(args: unknown[]): Promise { + return original.apply(arrowPatched, args); + } + (arrowPatched as { schedule: unknown }).schedule = (...args: unknown[]) => + scheduleWithOpts(args); + + const limiters = [ + track(new SubclassLimiter({ id: "override-subclass", datastore: "local" })), + track(new AsyncSubclassLimiter({ id: "override-async", datastore: "local" })), + arrowPatched, + ]; + for (const limiter of limiters) { + function callOverride(): Promise { + return (limiter.schedule as (...a: unknown[]) => Promise)(() => { + throw new Error("boom-override"); + }); + } + const [section] = scheduleSections(await callOverride().catch((e: unknown) => e)); + expect(section).toContain("callOverride"); + expect(section).not.toContain("scheduleWithOpts"); + expectNoLibraryFrames(section); + } + + // Bypassing the override: its cutoff isn't on the stack, so the first + // capture is empty and the base-cut fallback supplies the section. + function callBypass(): Promise { + return original.call(arrowPatched, () => { + throw new Error("boom-bypass"); + }); + } + const [section] = scheduleSections(await callBypass().catch((e: unknown) => e)); + expect(section).toContain("callBypass"); + expectNoLibraryFrames(section); + }); + + test("a bound schedule override keeps its wrapper frames", async ({ track }) => { + // V8 can't cut at a bound function, so the base cutoff is used instead. + const bound = track(new Bottleneck({ id: "override-bound", datastore: "local" })); + const original = Bottleneck.prototype.schedule as (...a: unknown[]) => Promise; + (bound as { schedule: unknown }).schedule = function scheduleWithOpts( + this: Bottleneck, + ...args: unknown[] + ) { + return original.apply(this, args); + }.bind(bound); + + function callBound(): Promise { + return (bound.schedule as (...a: unknown[]) => Promise)(() => { + throw new Error("boom-bound"); + }); + } + + const [section] = scheduleSections(await callBound().catch((e: unknown) => e)); + expect(section).toContain("callBound"); + expect(section).toContain("scheduleWithOpts"); + expectNoLibraryFrames(section); + }); + + test("a schedule nested in a task body appends both locations", async ({ makeLimiter }) => { + const inner = makeLimiter({ id: "task-body-inner", datastore: "local" }); + const outer = makeLimiter({ id: "task-body-outer", datastore: "local" }); + + function scheduleFromTaskBody(): Promise { + return outer.schedule(() => + inner.schedule(() => { + throw new Error("boom-task-body"); + }), + ) as Promise; + } + + const error = await scheduleFromTaskBody().catch((e: unknown) => e); + const stack = (error as Error).stack ?? ""; + expect(stack).toContain("boom-task-body"); + // Redis projects prefix limiter ids per test; match the stable suffix. + expect(stack).toContain("task-body-inner):"); + expect(stack).toContain("task-body-outer):"); + expect(stack).toContain("scheduleFromTaskBody"); + }); + + test("an explicit scheduleStackLabel is used for the marker", async ({ makeLimiter }) => { + const limiter = makeLimiter({ id: "pii-check-123", scheduleStackLabel: "check" }); + const error = await limiter + .schedule(() => { + throw new Error("boom-label"); + }) + .catch((e: unknown) => e); + const stack = (error as Error).stack ?? ""; + expect(stack).toContain("From previous Bottleneck.schedule location (check):"); + expect(stack).not.toContain("pii-check-123"); + }); + + test("a group-level scheduleStackLabel labels child markers", async ({ makeGroup }) => { + const group = makeGroup({ id: "tm-create-update", scheduleStackLabel: "payroll" }); + const limiter = group.key("acme-email-jane@acme.com"); + + const error = await limiter + .schedule(() => { + throw new Error("boom-group-label"); + }) + .catch((e: unknown) => e); + const stack = (error as Error).stack ?? ""; + expect(stack).toContain("From previous Bottleneck.schedule location (payroll):"); + expect(stack).not.toContain("jane@acme.com"); + }); +}); diff --git a/tsdown.config.mts b/tsdown.config.mts index 875b720..e7ded02 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -48,8 +48,7 @@ const excludeClustering: TsdownPlugin = { // `src/async-context.ts` statically imports `node:async_hooks`, which has no // browser equivalent. The light build redirects that import to a shim whose -// `bind` is the identity function, so tasks simply run unbound. Stack -// capture/attach (pure `Error` logic) keeps working untouched. +// `bind` is the identity function, so tasks simply run unbound. const shimAsyncHooks: TsdownPlugin = { name: "shim-async-hooks", resolveId(id) {