Skip to content

Commit 5d1b2ea

Browse files
ikreymertw4l
andauthored
Add Indexer options to commit/cancel single crawl (#978)
Add a --commitCrawlId and --cancelCrawlId to indexer which will either: - merge the the data for a single crawl into the main index. This can take a variable amount of time, depending on size of index. - improved logging for commit, and ensure counts are never incremented twice, even if long-running commit job is restarted. - delete data for canceled crawl from uncommitted crawls list (should be very fast). Both of these tasks also done as part of the crawl itself, and is still useful for single-crawler operation. However, with Browsertrix controlled in k8s, these tasks are better handled as separate jobs, especially since crawl may be committed without restarting the crawler container (eg. for paused crawls). --------- Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
1 parent b9f912e commit 5d1b2ea

5 files changed

Lines changed: 131 additions & 30 deletions

File tree

docs/docs/develop/dedupe.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ to the merged index. The following key is used to track uncomitted crawls:
4949

5050
If a crawl is canceled, these 3 keys should be removed and the crawl id should be removed from `uncommittedcrawls`
5151

52+
The indexer entrypoint includes a dedicated `indexer --cancelCrawlId <crawlid>` option to perform the cancellation operation.
53+
5254
### Committed Crawls / Merged Index Keys
5355

5456
Once a crawl is finished, the merged index keys are updated as follows:
@@ -60,6 +62,9 @@ Once a crawl is finished, the merged index keys are updated as follows:
6062

6163
- `allcounts`: A sum of all the `h:${crawlid}:counts` for all crawlids in `allcrawls`. The `allcounts` fields are incremented by each new `h:${crawlid}:counts`
6264

65+
Committing the crawl may take a long time, depending on the size of the crawl. For this reason, the indexer entrypoint
66+
includes a dedicated command to commit a single crawl, `indexer --commitCrawlId <crawlid>`.
67+
6368
#### Dedupe Lookup
6469

6570
To write a revisit record, two Redis lookups are needed:
@@ -126,6 +131,13 @@ the existing crawls, by rerunning the crawl 'commit' process for each existing c
126131

127132
The result is that all data related to removed crawls is purged, and the `removeCrawlSize` and `removeCrawls` counts are reset to 0.
128133

134+
### Committing and Canceling
135+
136+
The indexer entrypoint also includes options to commit or cancel a crawl, as explained above. These options,
137+
`--commitCrawlId <crawlid>` and `--cancelCrawlId <crawlid>` are useful when the crawler is controlled via k8s, e.g. with `--restartsOnError` set.
138+
139+
When the `--restartsOnError` flag is set, it is expected that the commit/cancel operations will be run separately, and so they are not run as part of the crawl. Otherwise, they are attempted as part of the crawl (after WACZ upload or upon cancellation).
140+
129141
## Crawl Dependency Tracking in WACZ datapackage.json
130142

131143
Deduplication adds an inherent dependency system, where a WACZ from one crawl (which contains `revisit` records) is now dependent on a WACZ from another crawl (which contains the original `response` records).

src/crawler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,7 +1681,7 @@ self.__bx_behaviors.selectMainBehavior();
16811681
}
16821682

16831683
async cleanupOnCancel() {
1684-
if (this.isExternalDedupeStore) {
1684+
if (this.isExternalDedupeStore && !this.params.restartsOnError) {
16851685
await this.crawlState.clearUncommitted();
16861686
}
16871687
}
@@ -2028,9 +2028,9 @@ self.__bx_behaviors.selectMainBehavior();
20282028
return;
20292029
}
20302030

2031-
if (this.isExternalDedupeStore) {
2031+
if (this.isExternalDedupeStore && !this.params.restartsOnError) {
20322032
// commit crawl data to main index
2033-
logger.info("Committing dedupe index");
2033+
logger.info("Committing dedupe index", {}, "dedupe");
20342034
await this.crawlState.commitDedupeDone();
20352035
}
20362036

src/indexer.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class CrawlIndexer {
4444
sourceUrl: {
4545
describe: "Source WACZ or Multi WACZ or Multi WACZ JSON to index",
4646
type: "string",
47-
required: true,
47+
required: false,
4848
},
4949

5050
sourceCrawlId: {
@@ -59,12 +59,24 @@ export class CrawlIndexer {
5959
required: false,
6060
default: false,
6161
},
62+
63+
commitCrawlId: {
64+
describe:
65+
"If provided, commit single uncommitted crawl to merged index and exit",
66+
type: "string",
67+
},
68+
69+
cancelCrawlId: {
70+
describe: "If provided, delete data for uncommitted crawl and exit",
71+
type: "string",
72+
},
6273
})
6374
.parseSync();
6475
}
6576

6677
async run() {
6778
logger.setDebugLogging(true);
79+
logger.setDefaultLogContext("dedupe");
6880

6981
process.on("SIGINT", () => this.handleInterrupt("SIGINT"));
7082

@@ -77,6 +89,27 @@ export class CrawlIndexer {
7789
const redis = await initRedisWaitForSuccess(params.redisDedupeUrl);
7890
const dedupeIndex = new RedisDedupeIndex(redis, "");
7991

92+
if (params.commitCrawlId) {
93+
// Commit one crawl and exit
94+
logger.info("Committing crawl to merged index", {
95+
crawlId: params.commitCrawlId,
96+
});
97+
await dedupeIndex.commitDedupeDone(params.commitCrawlId);
98+
process.exit(ExitCodes.Success);
99+
} else if (params.cancelCrawlId) {
100+
// Cancel one crawl and exit
101+
logger.info("Deleting data for cancelled uncommitted crawl", {
102+
crawlId: params.cancelCrawlId,
103+
});
104+
await dedupeIndex.clearUncommitted(params.cancelCrawlId);
105+
process.exit(ExitCodes.Success);
106+
} else if (!params.sourceUrl) {
107+
logger.fatal(
108+
"One of --commitCrawlId, --cancelCrawlId or --sourceUrl for import is required",
109+
);
110+
return;
111+
}
112+
80113
for await (const entry of this.iterWACZ({
81114
url: params.sourceUrl,
82115
name: basename(params.sourceUrl),

src/util/logger.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const LOG_CONTEXT_TYPES = [
5858
"proxy",
5959
"scope",
6060
"robots",
61+
"dedupe",
6162
] as const;
6263

6364
export type LogContext = (typeof LOG_CONTEXT_TYPES)[number];
@@ -79,9 +80,14 @@ class Logger {
7980
logLevels: string[] = [];
8081
contexts: LogContext[] = [];
8182
excludeContexts: LogContext[] = [];
83+
defaultLogContext: LogContext = "general";
8284
crawlState?: RedisCrawlState | null = null;
8385
fatalExitCode: ExitCodes = ExitCodes.Fatal;
8486

87+
setDefaultLogContext(value: LogContext) {
88+
this.defaultLogContext = value;
89+
}
90+
8591
setDefaultFatalExitCode(exitCode: number) {
8692
this.fatalExitCode = exitCode;
8793
}
@@ -185,19 +191,35 @@ class Logger {
185191
}
186192
}
187193

188-
info(message: string, data: unknown = {}, context: LogContext = "general") {
194+
info(
195+
message: string,
196+
data: unknown = {},
197+
context: LogContext = this.defaultLogContext,
198+
) {
189199
this.logAsJSON(message, data, context, "info");
190200
}
191201

192-
error(message: string, data: unknown = {}, context: LogContext = "general") {
202+
error(
203+
message: string,
204+
data: unknown = {},
205+
context: LogContext = this.defaultLogContext,
206+
) {
193207
this.logAsJSON(message, data, context, "error");
194208
}
195209

196-
warn(message: string, data: unknown = {}, context: LogContext = "general") {
210+
warn(
211+
message: string,
212+
data: unknown = {},
213+
context: LogContext = this.defaultLogContext,
214+
) {
197215
this.logAsJSON(message, data, context, "warn");
198216
}
199217

200-
debug(message: string, data: unknown = {}, context: LogContext = "general") {
218+
debug(
219+
message: string,
220+
data: unknown = {},
221+
context: LogContext = this.defaultLogContext,
222+
) {
201223
if (this.debugLogging) {
202224
this.logAsJSON(message, data, context, "debug");
203225
}
@@ -206,7 +228,7 @@ class Logger {
206228
fatal(
207229
message: string,
208230
data = {},
209-
context: LogContext = "general",
231+
context: LogContext = this.defaultLogContext,
210232
exitCode = ExitCodes.Success,
211233
) {
212234
exitCode = exitCode || this.fatalExitCode;

src/util/state.ts

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -273,19 +273,32 @@ export class RedisDedupeIndex {
273273

274274
// COMMIT DEDUPE TO SHARED INDEX
275275

276-
async commitDedupeDone(crawlId?: string) {
276+
async commitDedupeDone(crawlId?: string, uncommitted_key = DUPE_UNCOMMITTED) {
277277
crawlId = crawlId || this.crawlId;
278278

279-
// set this first, as crawl is considered 'committed' as soon
280-
// as data is partially merged in
281-
await this.dedupeRedis.srem(DUPE_UNCOMMITTED, crawlId);
282-
await this.dedupeRedis.sadd(DUPE_ALL_CRAWLS, crawlId);
279+
// check if already committed
280+
if (!(await this.dedupeRedis.sismember(uncommitted_key, crawlId))) {
281+
logger.warn(
282+
"Crawl not found for committing, or already committed",
283+
{
284+
crawlId,
285+
},
286+
"dedupe",
287+
);
288+
return;
289+
}
290+
291+
let totalHashes = 0;
292+
let newHashes = 0;
283293

284294
for await (const hashes of this.dedupeRedis.hscanStream(`h:${crawlId}`)) {
285295
let isValue = false;
286296
for (const hash of hashes) {
287297
if (!isValue) {
288-
await this.dedupeRedis.hsetnx(DUPE_ALL_HASH_KEY, hash, crawlId);
298+
if (await this.dedupeRedis.hsetnx(DUPE_ALL_HASH_KEY, hash, crawlId)) {
299+
newHashes++;
300+
}
301+
totalHashes++;
289302
}
290303
isValue = !isValue;
291304
}
@@ -307,8 +320,36 @@ export class RedisDedupeIndex {
307320
}
308321
}
309322

310-
// add counts
311-
await this.addCrawlCounts(crawlId);
323+
logger.info(
324+
"Crawl committed to merged index!",
325+
{
326+
crawlId,
327+
totalHashes,
328+
newHashes,
329+
numWacz,
330+
},
331+
"dedupe",
332+
);
333+
334+
// move to all crawls here
335+
if (
336+
!(await this.dedupeRedis.smove(uncommitted_key, DUPE_ALL_CRAWLS, crawlId))
337+
) {
338+
// if already moved, return here to avoid duplicating counts
339+
logger.warn(
340+
"Crawl counts not updated, already added to allcounts",
341+
{},
342+
"dedupe",
343+
);
344+
return;
345+
}
346+
347+
const counts = await this.dedupeRedis.hgetall(`h:${crawlId}:counts`);
348+
for (const [key, value] of Object.entries(counts)) {
349+
await this.dedupeRedis.hincrby(DUPE_ALL_COUNTS, key, Number(value));
350+
}
351+
352+
logger.debug("Crawl counts added to allcounts", {}, "dedupe");
312353
}
313354

314355
// ADD UNCOMITTED CRAWL
@@ -319,10 +360,11 @@ export class RedisDedupeIndex {
319360
}
320361

321362
// CLEAR UNCOMMITTED
322-
async clearUncommitted() {
323-
if (this.crawlId) {
324-
await this.dedupeRedis.srem(DUPE_UNCOMMITTED, this.crawlId);
325-
await this.deleteCrawlDedupeKeys(this.crawlId);
363+
async clearUncommitted(crawlId?: string) {
364+
crawlId ||= this.crawlId;
365+
if (crawlId) {
366+
await this.dedupeRedis.srem(DUPE_UNCOMMITTED, crawlId);
367+
await this.deleteCrawlDedupeKeys(crawlId);
326368
}
327369
}
328370

@@ -413,13 +455,6 @@ export class RedisDedupeIndex {
413455
pipe.hincrby(key, "totalCrawlSize", value);
414456
}
415457

416-
async addCrawlCounts(crawlId: string) {
417-
const counts = await this.dedupeRedis.hgetall(`h:${crawlId}:counts`);
418-
for (const [key, value] of Object.entries(counts)) {
419-
await this.dedupeRedis.hincrby(DUPE_ALL_COUNTS, key, Number(value));
420-
}
421-
}
422-
423458
// IMPORT
424459

425460
async queueImportSource(id: string, data: string) {
@@ -674,8 +709,7 @@ export class RedisDedupeIndex {
674709
// readd all crawls that should be kept
675710
for (const crawlId of readdCrawls) {
676711
await this.setUpdateProgress(0.5 + 0.5 * (count++ / numCrawls));
677-
await this.commitDedupeDone(crawlId);
678-
await this.dedupeRedis.srem(TO_REMOVE_CRAWLS, crawlId);
712+
await this.commitDedupeDone(crawlId, TO_REMOVE_CRAWLS);
679713
}
680714

681715
// clear data for remaining

0 commit comments

Comments
 (0)