Skip to content

Commit 514c1d0

Browse files
fix: bound the standby crawler set
1 parent 4061ca5 commit 514c1d0

15 files changed

Lines changed: 291 additions & 170 deletions

‎actors/apify_rag-web-browser/.actor/input_schema.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
"proxyConfiguration": {
7676
"title": "Proxy configuration",
7777
"type": "object",
78-
"description": "Apify Proxy configuration used for scraping the target web pages.",
78+
"description": "Apify Proxy configuration used for scraping the target web pages. In Standby mode this applies to the shared crawlers started with the Actor, so it cannot be set per request.",
7979
"default": {
8080
"useApifyProxy": true
8181
},
@@ -119,8 +119,8 @@
119119
"desiredConcurrency": {
120120
"title": "Desired browsing concurrency",
121121
"type": "integer",
122-
"description": "The desired number of web browsers running in parallel. The system automatically scales the number based on the CPU and memory usage. If the initial value is `0`, the Actor picks the number automatically based on the available memory.",
123-
"minimum": 0,
122+
"description": "The desired number of web browsers running in parallel. The system automatically scales the number based on the CPU and memory usage. In Standby mode this applies to the shared crawlers started with the Actor, so it cannot be set per request.",
123+
"minimum": 1,
124124
"maximum": 50,
125125
"default": 5,
126126
"editor": "hidden"

‎actors/apify_url-to-markdown/.actor/input_schema.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"sectionCaption": "Scraping settings",
3131
"title": "Proxy configuration",
3232
"type": "object",
33-
"description": "Apify Proxy configuration used for scraping the target web pages.",
33+
"description": "Apify Proxy configuration used for scraping the target web pages. In Standby mode this applies to the shared crawlers started with the Actor, so it cannot be set per request.",
3434
"default": {
3535
"useApifyProxy": true
3636
},
@@ -50,8 +50,8 @@
5050
"desiredConcurrency": {
5151
"title": "Desired browsing concurrency",
5252
"type": "integer",
53-
"description": "The desired number of web browsers running in parallel. The system automatically scales the number based on the CPU and memory usage. If the initial value is `0`, the Actor picks the number automatically based on the available memory.",
54-
"minimum": 0,
53+
"description": "The desired number of web browsers running in parallel. The system automatically scales the number based on the CPU and memory usage. In Standby mode this applies to the shared crawlers started with the Actor, so it cannot be set per request.",
54+
"minimum": 1,
5555
"maximum": 50,
5656
"default": 1,
5757
"editor": "hidden"

‎docs/standby-openapi-3.1.0.json‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,6 @@
8888
"default": 2
8989
}
9090
},
91-
{
92-
"name": "proxyConfiguration",
93-
"in": "query",
94-
"description": "Apify Proxy configuration used for scraping the target web pages, as a JSON-encoded object string.",
95-
"required": false,
96-
"schema": {
97-
"type": "string",
98-
"default": "{\"useApifyProxy\": true}"
99-
},
100-
"example": "{\"useApifyProxy\": true}"
101-
},
10291
{
10392
"name": "scrapingTool",
10493
"in": "query",

‎docs/url-to-markdown-standby-openapi-3.1.0.json‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,6 @@
4040
],
4141
"default": "raw-http"
4242
}
43-
},
44-
{
45-
"name": "proxyConfiguration",
46-
"in": "query",
47-
"description": "Apify Proxy configuration used for scraping the target web pages, as a JSON-encoded object string.",
48-
"required": false,
49-
"schema": {
50-
"type": "string",
51-
"default": "{\"useApifyProxy\": true}"
52-
},
53-
"example": "{\"useApifyProxy\": true}"
5443
}
5544
],
5645
"responses": {

‎src/const.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,8 @@ export type CrawlerKind = 'search' | ContentCrawlerTypes;
2222

2323
export const PLAYWRIGHT_REQUEST_TIMEOUT_NORMAL_MODE_SECS = 60;
2424

25+
// The widest values the input schema allows; each request narrows them down to its own.
26+
export const CRAWLER_MAX_REQUEST_RETRIES = 5;
27+
export const CRAWLER_REQUEST_HANDLER_TIMEOUT_SECS = 300;
28+
2529
export const GOOGLE_STANDARD_RESULTS_PER_PAGE = 10;

‎src/crawlers.ts‎

Lines changed: 67 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ import type {
3232
} from './types.js';
3333
import { addTimeMeasureEvent, createRequest, createSearchRequest, isActorStandby, randomId } from './utils.js';
3434

35-
const crawlers = new Map<string, CheerioCrawler | PlaywrightCrawler>();
35+
type ContentCrawler = CheerioCrawler | PlaywrightCrawler;
36+
37+
// Pending rather than built, so concurrent requests for one key don't each build a crawler and
38+
// orphan all but the last.
39+
const crawlers = new Map<string, Promise<ContentCrawler>>();
3640
const client = new MemoryStorage({ persistStorage: false });
3741

3842
const contentCrawlerHttpClient = new ImpitHttpClient({
@@ -97,27 +101,53 @@ function canonicalJson(value: unknown): string {
97101
}
98102

99103
/**
100-
* Identifies a crawler in the `crawlers` cache. Listed are the only options the builders in
101-
* `input.ts` derive from the input; the proxy options stand in for the constructed
102-
* `ProxyConfiguration`, whose child logger snapshots the log level and so kept changing the key.
103-
* Hashed because the key is logged and used as a queue name, while the options can hold credentials.
104+
* The proxy options stand in for the constructed `ProxyConfiguration`, whose child logger snapshots
105+
* the log level and so kept changing the key. Hashed because the key is logged and used as a queue
106+
* name, while the options can hold credentials.
104107
*/
105-
export function getCrawlerKey(
106-
kind: CrawlerKind,
107-
crawlerOptions: CheerioCrawlerOptions | PlaywrightCrawlerOptions,
108-
proxyOptions: ProxyOptions,
109-
): string {
110-
const fingerprint = {
111-
keepAlive: crawlerOptions.keepAlive,
112-
maxRequestRetries: crawlerOptions.maxRequestRetries,
113-
requestHandlerTimeoutSecs: crawlerOptions.requestHandlerTimeoutSecs,
114-
desiredConcurrency: crawlerOptions.autoscaledPoolOptions?.desiredConcurrency,
115-
proxy: resolveProxyOptions(proxyOptions),
116-
};
117-
const hash = createHash('sha1').update(canonicalJson(fingerprint)).digest('hex');
108+
export function getCrawlerCount() {
109+
return crawlers.size;
110+
}
111+
112+
export function getCrawlerKey(kind: CrawlerKind, proxyOptions: ProxyOptions): string {
113+
const hash = createHash('sha1').update(canonicalJson(resolveProxyOptions(proxyOptions))).digest('hex');
118114
return `${kind}-${hash.slice(0, 12)}`;
119115
}
120116

117+
/** A crawler that fails to build or to run is dropped, so the cache can never hand out a dead one. */
118+
async function getOrCreateCrawler(
119+
key: string,
120+
kind: CrawlerKind,
121+
startCrawler: boolean,
122+
build: () => Promise<ContentCrawler>,
123+
): Promise<ContentCrawler> {
124+
const cached = crawlers.get(key);
125+
if (cached) {
126+
return cached;
127+
}
128+
129+
const pending = (async () => {
130+
log.info(`Creating new ${kind} crawler with key ${key}`);
131+
const crawler = await build();
132+
if (startCrawler) {
133+
crawler.run().then(
134+
() => log.warning(`Crawler ${kind} has finished`),
135+
(err) => {
136+
log.error(`Crawler ${kind} failed to run: ${err instanceof Error ? err.message : String(err)}`);
137+
crawlers.delete(key);
138+
},
139+
);
140+
log.info(`Crawler ${kind} has started 💪🏼`);
141+
}
142+
return crawler;
143+
})();
144+
145+
crawlers.set(key, pending);
146+
pending.catch(() => crawlers.delete(key));
147+
log.info(`Number of crawlers ${crawlers.size}`);
148+
return pending;
149+
}
150+
121151
/**
122152
* Adds a content crawl request to selected content crawler.
123153
* Get existing crawler based on crawlerOptions and scraperSettings, if not present -> create new
@@ -127,13 +157,14 @@ export const addContentCrawlRequest = async (
127157
responseId: string,
128158
contentCrawlerKey: string,
129159
) => {
130-
const crawler = crawlers.get(contentCrawlerKey);
131-
const name = crawler instanceof PlaywrightCrawler ? 'playwright' : 'cheerio';
132-
133-
if (!crawler) {
160+
const pending = crawlers.get(contentCrawlerKey);
161+
if (!pending) {
134162
log.error(`Content crawler not found: key ${contentCrawlerKey}`);
135163
return;
136164
}
165+
166+
const crawler = await pending;
167+
const name = crawler instanceof PlaywrightCrawler ? 'playwright' : 'cheerio';
137168
try {
138169
await crawler.requestQueue!.addRequest(request);
139170
// create an empty result in search request response
@@ -154,13 +185,8 @@ export async function createAndStartSearchCrawler(
154185
startCrawler = true,
155186
) {
156187
const { crawlerOptions, proxyOptions } = searchCrawlerOptions;
157-
const key = getCrawlerKey('search', crawlerOptions, proxyOptions);
158-
if (crawlers.has(key)) {
159-
return { key, crawler: crawlers.get(key) };
160-
}
161-
162-
log.info(`Creating new cheerio crawler with key ${key}`);
163-
const crawler = new CheerioCrawler({
188+
const key = getCrawlerKey('search', proxyOptions);
189+
const crawler = await getOrCreateCrawler(key, 'search', startCrawler, async () => new CheerioCrawler({
164190
...crawlerOptions,
165191
requestQueue: await RequestQueue.open(key, { storageClient: client }),
166192
requestHandler: async ({ request, $: _$, addRequests }: CheerioCrawlingContext<SearchCrawlerUserData>) => {
@@ -232,17 +258,8 @@ export async function createAndStartSearchCrawler(
232258
const errorResponse = { errorMessage: err.message };
233259
sendResponseError(request.uniqueKey, JSON.stringify(errorResponse));
234260
},
235-
});
236-
if (startCrawler) {
237-
crawler.run().then(
238-
() => log.warning('Google-search-crawler has finished'),
239-
// eslint-disable-next-line @typescript-eslint/no-empty-function
240-
() => { },
241-
);
242-
log.info('Google-search-crawler has started 🫡');
243-
}
244-
crawlers.set(key, crawler);
245-
log.info(`Number of crawlers ${crawlers.size}`);
261+
}));
262+
246263
return { key, crawler };
247264
}
248265

@@ -257,25 +274,13 @@ export async function createAndStartContentCrawler(
257274
) {
258275
const { type: crawlerType, crawlerOptions, proxyOptions } = contentCrawlerOptions;
259276

260-
const key = getCrawlerKey(crawlerType, crawlerOptions, proxyOptions);
261-
if (crawlers.has(key)) {
262-
return { key, crawler: crawlers.get(key) };
263-
}
277+
const key = getCrawlerKey(crawlerType, proxyOptions);
278+
const crawler = await getOrCreateCrawler(key, crawlerType, startCrawler, async () => (
279+
crawlerType === ContentCrawlerTypes.PLAYWRIGHT
280+
? createPlaywrightContentCrawler(crawlerOptions, key)
281+
: createCheerioContentCrawler(crawlerOptions, key)
282+
));
264283

265-
const crawler = crawlerType === 'playwright'
266-
? await createPlaywrightContentCrawler(crawlerOptions, key)
267-
: await createCheerioContentCrawler(crawlerOptions, key);
268-
269-
if (startCrawler) {
270-
crawler.run().then(
271-
() => log.warning(`Crawler ${crawlerType} has finished`),
272-
// eslint-disable-next-line @typescript-eslint/no-empty-function
273-
() => { },
274-
);
275-
log.info(`Crawler ${crawlerType} has started 💪🏼`);
276-
}
277-
crawlers.set(key, crawler);
278-
log.info(`Number of crawlers ${crawlers.size}`);
279284
return { key, crawler };
280285
}
281286

@@ -288,7 +293,6 @@ async function createPlaywrightContentCrawler(
288293
crawlerOptions: PlaywrightCrawlerOptions,
289294
key: string,
290295
): Promise<PlaywrightCrawler> {
291-
log.info(`Creating new playwright crawler with key ${key}`);
292296
const blocker = await getGhosteryBlocker();
293297
return new PlaywrightCrawler({
294298
...crawlerOptions,
@@ -309,7 +313,6 @@ async function createCheerioContentCrawler(
309313
crawlerOptions: CheerioCrawlerOptions,
310314
key: string,
311315
): Promise<CheerioCrawler> {
312-
log.info(`Creating new cheerio crawler with key ${key}`);
313316
return new CheerioCrawler({
314317
...crawlerOptions,
315318
keepAlive: crawlerOptions.keepAlive,
@@ -397,12 +400,13 @@ export const addSearchRequest = async (
397400
request: RequestOptions<ContentCrawlerUserData>,
398401
searchCrawlerKey: string,
399402
) => {
400-
const crawler = crawlers.get(searchCrawlerKey);
401-
402-
if (!crawler) {
403+
const pending = crawlers.get(searchCrawlerKey);
404+
if (!pending) {
403405
log.error(`Search crawler not found: key ${searchCrawlerKey}`);
404406
return;
405407
}
408+
409+
const crawler = await pending;
406410
addTimeMeasureEvent(request.userData!, 'before-cheerio-queue-add');
407411
await crawler.requestQueue!.addRequest(request);
408412
log.info(`Added request to cheerio-google-search-crawler: ${request.url}`);

0 commit comments

Comments
 (0)