Skip to content

Commit cf50453

Browse files
feat(api): send team concurrency with FirePDF async submits (firecrawl#4051)
* feat(api): send team concurrency with FirePDF async submits FirePDF's per-team admission (its ENG-5049) runs in observation mode and needs the account context to measure anything. Submit now carries team_concurrency from the cached ACUC lookup, best-effort: lookup failure or absence never blocks a scrape — FirePDF simply skips team observation for that submit. team_tier is deliberately NOT sent: no plan/tier string exists in this package today; FirePDF defaults the tier for observation, and the authoritative tier source is being decided alongside its entitlement policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: snapshot team concurrency via internalOptions, not a re-fetch Per review: the ACUC is already in hand at the controller, so thread it down instead of re-reading in the worker. teamConcurrency is snapshotted from req.acuc at the same two sites that snapshot teamFlags (v2 scrape + parse) and rides the job payload; the FirePDF async engine reads meta.internalOptions.teamConcurrency and does no lookup. Also pins the entitlement to acceptance time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 16d5e39 commit cf50453

6 files changed

Lines changed: 83 additions & 3 deletions

File tree

apps/api/src/controllers/v2/parse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ export async function parseController(
512512
bypassBilling: isDirectToBullMQ || !shouldBill,
513513
zeroDataRetention,
514514
teamFlags: req.acuc?.flags ?? null,
515+
teamConcurrency: req.acuc?.concurrency ?? null,
515516
uploadedFile: file,
516517
forceEngine,
517518
isParse: true,

apps/api/src/controllers/v2/scrape.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ export async function scrapeController(
328328
bypassBilling: isDirectToBullMQ || !shouldBill,
329329
zeroDataRetention,
330330
teamFlags: req.acuc?.flags ?? null,
331+
teamConcurrency: req.acuc?.concurrency ?? null,
331332
agentIndexOnly: (req as any).agentIndexOnly ?? false,
332333
threatProtection: threatProtection.policy ?? undefined,
333334
},

apps/api/src/scraper/scrapeURL/engines/pdf/__tests__/firePDFAsync.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ vi.mock("../../../../../lib/gcs-pdf-cache", () => ({
77
savePdfResultToCache: vi.fn(async () => null),
88
}));
99

10+
1011
import {
1112
FirePdfAsyncFailure,
1213
scrapePDFWithFirePDFAsync,
@@ -71,6 +72,7 @@ function makeMeta(overrides: Record<string, unknown> = {}) {
7172
internalOptions: {
7273
zeroDataRetention: false,
7374
teamId: "team-x",
75+
teamConcurrency: 12,
7476
crawlId: undefined,
7577
},
7678
options: {
@@ -91,11 +93,18 @@ function makeFetchFromSequence(
9193
url: string;
9294
method: string;
9395
headers: Record<string, string> | undefined;
96+
body: unknown;
9497
}> = [];
9598
const cursor = { idx: 0 };
9699
const fetchImpl: any = async (url: string, init: any) => {
97100
const method = (init?.method ?? "GET").toUpperCase();
98-
calls.push({ url, method, headers: init?.headers });
101+
let body: unknown;
102+
try {
103+
body = init?.body ? JSON.parse(init.body) : undefined;
104+
} catch {
105+
body = init?.body;
106+
}
107+
calls.push({ url, method, headers: init?.headers, body });
99108
const matcher = matchers[cursor.idx++];
100109
if (!matcher) {
101110
throw new Error(
@@ -136,6 +145,7 @@ describe("scrapePDFWithFirePDFAsync", () => {
136145
internalOptions: {
137146
zeroDataRetention: true,
138147
teamId: "team-x",
148+
teamConcurrency: 12,
139149
crawlId: undefined,
140150
},
141151
});
@@ -241,6 +251,49 @@ describe("scrapePDFWithFirePDFAsync", () => {
241251
expect(result.pagesProcessed).toBe(12);
242252
expect(fallback).not.toHaveBeenCalled();
243253
expect(calls).toHaveLength(4);
254+
// Account context rides the submit body (FirePDF ENG-5049).
255+
expect((calls[0].body as { team_concurrency?: number }).team_concurrency).toBe(12);
256+
});
257+
258+
it("submits without team context when the snapshot is absent", async () => {
259+
const { fetchImpl, calls } = makeFetchFromSequence([
260+
{
261+
matchUrl: /\/jobs$/,
262+
matchMethod: "POST",
263+
response: {
264+
status: 200,
265+
body: { scrape_id: "scrape-id-test", status: "done", lane: "fast", retry_after_ms: 0 },
266+
},
267+
},
268+
{
269+
matchUrl: /\/jobs\/scrape-id-test\/result$/,
270+
matchMethod: "GET",
271+
response: {
272+
status: 200,
273+
body: {
274+
schema_version: 1,
275+
markdown: "# No context",
276+
pages_processed: 1,
277+
failed_pages: null,
278+
partial_pages: null,
279+
},
280+
},
281+
},
282+
]);
283+
const fallback = vi.fn();
284+
285+
const meta = makeMeta();
286+
meta.internalOptions.teamConcurrency = null;
287+
const result = await scrapePDFWithFirePDFAsync(meta, "BASE64", undefined, undefined, undefined, {
288+
fetchImpl,
289+
fallbackImpl: fallback,
290+
sleepImpl: noopSleep,
291+
});
292+
293+
// Missing snapshot must never block the scrape — field simply absent.
294+
expect(result.markdown).toBe("# No context");
295+
expect((calls[0].body as { team_concurrency?: number }).team_concurrency).toBeUndefined();
296+
expect(fallback).not.toHaveBeenCalled();
244297
});
245298

246299
it("cancels accepted work when polling is abandoned", async () => {
@@ -282,7 +335,7 @@ describe("scrapePDFWithFirePDFAsync", () => {
282335

283336
expect(error).toBeInstanceOf(FirePdfAsyncFailure);
284337
expect(error.reason).toBe("network_error");
285-
expect(calls).toEqual([
338+
expect(calls.map(({ url, method, headers }) => ({ url, method, ...(headers !== undefined && { headers }) }))).toEqual([
286339
{
287340
url: "http://fire-pdf.test/jobs",
288341
method: "POST",
@@ -401,7 +454,7 @@ describe("scrapePDFWithFirePDFAsync", () => {
401454
expect(err).toBeInstanceOf(FirePdfAsyncFailure);
402455
expect(err.reason).toBe("network_error");
403456
expect(fallback).not.toHaveBeenCalled();
404-
expect(calls).toEqual([
457+
expect(calls.map(({ url, method }) => ({ url, method }))).toEqual([
405458
{ method: "POST", url: "http://fire-pdf.test/jobs" },
406459
{
407460
method: "DELETE",

apps/api/src/scraper/scrapeURL/engines/pdf/fire-pdf/async.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ export async function scrapePDFWithFirePDFAsync(
8282
const deadlineAt = new Date(submitTime + deadlineFromNow).toISOString();
8383
const pollingDeadline = submitTime + deadlineFromNow + POLL_TIMEOUT_BUFFER_MS;
8484

85+
// Account context for FirePDF's per-team admission observation,
86+
// snapshotted from the request ACUC into internalOptions at
87+
// acceptance (same pattern as teamFlags) — no re-fetch here. Absence
88+
// means FirePDF skips team observation for this submit.
89+
const rawConcurrency = meta.internalOptions.teamConcurrency;
90+
const teamConcurrency =
91+
typeof rawConcurrency === "number" && rawConcurrency > 0
92+
? rawConcurrency
93+
: undefined;
94+
8595
// ── Step 1: POST /jobs ────────────────────────────────────────────────
8696
let submissionAccepted = false;
8797
let terminalReached = false;
@@ -96,6 +106,7 @@ export async function scrapePDFWithFirePDFAsync(
96106
pagesProcessed,
97107
mode,
98108
deadlineAt,
109+
teamConcurrency,
99110
fetchImpl,
100111
});
101112
submissionAccepted = true;

apps/api/src/scraper/scrapeURL/engines/pdf/fire-pdf/submit.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ type SubmitArgs = {
2020
pagesProcessed: number | undefined;
2121
mode: PDFMode | undefined;
2222
deadlineAt: string;
23+
/** Team's sold concurrency from the ACUC (ENG-5049 account context).
24+
* Optional: entitlement lookup must never block or fail a scrape. */
25+
teamConcurrency: number | undefined;
2326
fetchImpl: typeof undiciFetch;
2427
};
2528

@@ -56,6 +59,7 @@ export async function submitJob(args: SubmitArgs): Promise<SubmitOutcome> {
5659
pagesProcessed,
5760
mode,
5861
deadlineAt,
62+
teamConcurrency,
5963
fetchImpl,
6064
} = args;
6165
const scrapeId = meta.id;
@@ -72,6 +76,12 @@ export async function submitJob(args: SubmitArgs): Promise<SubmitOutcome> {
7276
...(meta.internalOptions.crawlId && {
7377
crawl_id: meta.internalOptions.crawlId,
7478
}),
79+
// FirePDF per-team admission observation (its ENG-5049): sold
80+
// concurrency from the account context. Absence means FirePDF
81+
// skips team observation for this submit — never a rejection.
82+
...(teamConcurrency !== undefined && {
83+
team_concurrency: teamConcurrency,
84+
}),
7585
options: {
7686
...(pagesProcessed !== undefined && { pages_estimate: pagesProcessed }),
7787
...(maxPages !== undefined && { max_pages: maxPages }),

apps/api/src/scraper/scrapeURL/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,10 @@ export type InternalOptions = {
493493
bypassBilling?: boolean;
494494
zeroDataRetention?: boolean;
495495
teamFlags?: TeamFlags;
496+
/** Team's sold concurrency, snapshotted from the request ACUC at
497+
* acceptance (same pattern as teamFlags). Rides the job payload so
498+
* downstream engines (FirePDF async account context) never re-fetch. */
499+
teamConcurrency?: number | null;
496500

497501
/**
498502
* Effective threat protection policy for this scrape, resolved at the

0 commit comments

Comments
 (0)