Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Bottleneck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -555,6 +555,7 @@ class Bottleneck {
captureScheduleLocation(
this.captureScheduleStack ? (this.scheduleStackLabel ?? this.id) : null,
Bottleneck.prototype.schedule,
this.schedule,
),
);
this._receive(job);
Expand Down
10 changes: 2 additions & 8 deletions src/Job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
171 changes: 0 additions & 171 deletions src/async-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,174 +88,3 @@ export function setDetachedTimeout(
): ReturnType<typeof setTimeout> {
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<T>(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<T>(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<ScheduleStackCapture>;
via: string[];
sections: number;
omitted: number;
lastStart: number;
written: string;
};

const annotations = new WeakMap<object, Annotation>();

/**
* 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<T>(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;
}
Loading
Loading