Skip to content

Commit 904107c

Browse files
ikreymertw4l
andauthored
dedupe: add --dedupeConcurrent to be able to dedupe concurrent crawls (#1065)
- crawl cancelation with dedupeConcurrent: track of canceled concurrent - crawls in 'canceledcrawls' key, to be treated as removed crawls and purged - docs: update docs for --dedupeConcurrent trade-offs and update cli page - dedupe: account for page urls that redirect by checking for finalPageUrl - fixes #1064 --------- Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
1 parent 624128b commit 904107c

9 files changed

Lines changed: 133 additions & 27 deletions

File tree

docs/docs/develop/dedupe.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,20 @@ Once a crawl is finished, the merged index keys are updated as follows:
6060

6161
- `allhashes`: `{[hash]: [crawlid]}` - The main merged index. For each hash in `h:${crawlid}`, an entry is added mapping `hash` to the `crawlid`, indicating the hash is found in this crawl. The full data from `h:${crawlid}` is not copied to save space.
6262

63-
- `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`
63+
- `allcounts`: A sum of all the `h:${crawlid}:counts` for all crawl ids in `allcrawls`. The `allcounts` fields are incremented by each new `h:${crawlid}:counts`.
64+
65+
- `canceledcrawls`: Use with concurrent crawls only to store crawl ids of crawls that were cancelled or have failed
66+
while being deduped. If this key exists, indicates there may be data missing, if it has not been re-crawled.
6467

6568
Committing the crawl may take a long time, depending on the size of the crawl. For this reason, the indexer entrypoint
6669
includes a dedicated command to commit a single crawl, `indexer --commitCrawlId <crawlid>`.
6770

71+
### Concurrent Crawl Support
72+
73+
If running with `--dedupeConcurrent`, each new hash is automatically committed to `allhashes` and `allcounts` is updated
74+
for every new hash. If a concurrent crawl is canceled, it is additionally tracked in `canceledcrawls` to indicate that it
75+
needs to be included in the removed crawl count.
76+
6877
#### Dedupe Lookup
6978

7079
To write a revisit record, two Redis lookups are needed:

docs/docs/user-guide/cli-options.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ Options:
203203
duplicate pages can be skipped. -1 m
204204
eans never skip duplicate pages
205205
[number] [default: -1]
206+
--dedupeConcurrent If set, immediately add deduped URLs
207+
to the main dedupe index instead of
208+
to uncommittedcrawls. This allows d
209+
edupe of concurrently running crawls
210+
, but makes cancelling or failed cra
211+
wls potentially lossy
212+
[boolean] [default: false]
206213
--saveState If the crawl state should be seriali
207214
zed to the crawls/ directory. Defaul
208215
ts to 'partial', only saved when cra

docs/docs/user-guide/dedupe.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,18 @@ been archived, it will not be saved again, instead a [`revisit` record will be c
2626

2727
While the index needs to be Redis-compatible, any Redis-compatible database can be used as well without additional changes. [Apache Kvrocks](https://kvrocks.apache.org/) is a good choice for the dedupe index database as it persists the data on disk, instead of keeping it all in memory like Redis.
2828

29-
When multiple crawls are running at the same time, the resources from one crawl are not yet available to the other crawls.
30-
This is to account for crawls that may be cancelled or fail.
29+
30+
### Crawl Cancellation and Concurrent Crawl support
31+
32+
By default, when multiple crawls are running at the same time, the resources from one crawl are not yet available to the other crawls.
33+
This is to account for crawls that may be cancelled or fail. If the crawl is cancelled/failed, the data is not entered into the shared dedupe index.
3134

3235
Once a crawl is complete, its data is fully 'committed' to the index and available to be deduplicated against by future crawls. This happens automatically when running the crawler via command line but can also be triggered [via a special indexer command](#committing-finished-crawls-to-the-index).
3336

37+
Sometimes it is necessary to deduplicate multiple concurrently running crawls. The `--dedupeConcurrent` flag can be used to ensure
38+
the dedupe index is updated immediately while the crawl is running. As a trade-off, there may be data loss if the crawl is cancelled or fails, as other crawls may deduplicate against records from a crawl whose data is discarded and not stored.
39+
For this reason, concurrent deduplication is not the default behavior, but may be enabled if concurrent crawls will not be canceled and will be retried on failure.
40+
3441
### Example: Running crawls with deduplication
3542

3643
Here's a quick example of running crawls with dedupe against the same index:
@@ -98,9 +105,13 @@ docker run -it webrecorder/browsertrix-crawler indexer --commitCrawlId <crawl-id
98105

99106
The commit process may take a long time if it was a large crawl, but generally should finish quickly.
100107

108+
If running with `--dedupeConcurrent`, no commit operation is needed as the data is automatically committed.
109+
101110
### Handling interrupted or canceled crawls
102111

103-
If a crawler is interrupted, eg. with SIGINT, the dedupe data stored for that crawl will not yet committed.
112+
If a crawler is interrupted, eg. with SIGINT, the dedupe data stored for that crawl will not yet be committed
113+
(unless `--dedupeConcurrent` was used, see below).
114+
104115
Since the crawl has not finished, the user may or may not want to include the data in the dedupe index.
105116

106117
To include the partially finished crawl, run the above command with `--commitCrawlId`.
@@ -114,6 +125,14 @@ docker run -it webrecorder/browsertrix-crawler indexer --cancelCrawlId <crawl-id
114125
This operational is not required - the data from the interrupted crawl will not be used in further dedupe, it will
115126
simply free up the data on the Redis.
116127

128+
#### With concurrent crawl support
129+
130+
If the crawl was run with `--dedupeConcurrent` and is cancelled or fails, the crawl is tracked in a list of removed crawls that
131+
should be purged. The crawl's data will be removed from the index when the `indexer --remove` operation is performed.
132+
133+
Since other crawl data may have been deduplicated against the failed crawl, it is possible that cancelled or failed crawls may result in missing data
134+
if the crawl is marked as dependency of another crawl. Rerunning the crawl may patch the missing data in this case.
135+
117136

118137
## Page Deduplication
119138

src/crawler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ export class Crawler {
399399
os.hostname(),
400400
this.params.maxPageRetries,
401401
dedupeRedis,
402+
this.params.dedupeConcurrent,
402403
this.params.rateLimitTimeout,
403404
this.params.rateLimitInterruptCount,
404405
this.params.rateLimitMaxRetries,
@@ -1875,7 +1876,7 @@ self.__bx_behaviors.selectMainBehavior();
18751876
}
18761877
}
18771878
if (this.isExternalDedupeStore) {
1878-
await this.crawlState.addUncommited();
1879+
await this.crawlState.addCrawlForDedupe();
18791880
}
18801881

18811882
if (POST_CRAWL_STATES.includes(initState)) {

src/indexer.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class CrawlIndexer {
8787
const params = this.initArgs();
8888

8989
const redis = await initRedisWaitForSuccess(params.redisDedupeUrl);
90-
const dedupeIndex = new RedisDedupeIndex(redis, "");
90+
const dedupeIndex = new RedisDedupeIndex(redis, "", false);
9191

9292
if (params.commitCrawlId) {
9393
// Commit one crawl and exit
@@ -97,11 +97,20 @@ export class CrawlIndexer {
9797
await dedupeIndex.commitDedupeDone(params.commitCrawlId);
9898
process.exit(ExitCodes.Success);
9999
} 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);
100+
if (await dedupeIndex.clearUncommitted(params.cancelCrawlId)) {
101+
// Cancel one crawl and exit
102+
logger.info("Deleting data for cancelled uncommitted crawl", {
103+
crawlId: params.cancelCrawlId,
104+
});
105+
} else if (await dedupeIndex.markCanceledCrawl(params.cancelCrawlId)) {
106+
logger.info("Crawl already committed, mark crawl for removal", {
107+
crawlId: params.cancelCrawlId,
108+
});
109+
} else {
110+
logger.info("Canceled crawl id not found, exiting", {
111+
crawlId: params.cancelCrawlId,
112+
});
113+
}
105114
process.exit(ExitCodes.Success);
106115
} else if (!params.sourceUrl) {
107116
await logger.fatal(

src/util/argParser.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,13 @@ class ArgParser {
476476
default: -1,
477477
},
478478

479+
dedupeConcurrent: {
480+
describe:
481+
"If set, immediately add deduped URLs to the main dedupe index instead of to uncommittedcrawls. This allows dedupe of concurrently running crawls, but makes cancelling or failed crawls potentially lossy",
482+
type: "boolean",
483+
default: false,
484+
},
485+
479486
saveState: {
480487
describe:
481488
"If the crawl state should be serialized to the crawls/ directory. Defaults to 'partial', only saved when crawl is interrupted",

src/util/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const DUPE_ALL_HASH_KEY = "alldupes";
2626
export const DUPE_ALL_CRAWLS = "allcrawls";
2727
export const DUPE_ALL_COUNTS = "allcounts";
2828
export const DUPE_UNCOMMITTED = "uncommittedcrawls";
29+
export const DUPE_CANCELED_CRAWLS = "canceledcrawls";
2930

3031
export enum BxFunctionBindings {
3132
BehaviorLogFunc = "__bx_log",

src/util/recorder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -893,7 +893,7 @@ export class Recorder extends EventEmitter {
893893
// write a revisit record, track pages as a duplicate, and abort the page
894894
if (
895895
!streamingConsume &&
896-
url === this.pageUrl &&
896+
url === this.finalPageUrl &&
897897
reqresp.payload &&
898898
this.dedupePagesMinDepth >= 0 &&
899899
this.pageSeedDepth >= this.dedupePagesMinDepth

src/util/state.ts

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DUPE_ALL_HASH_KEY,
1111
DUPE_ALL_CRAWLS,
1212
DUPE_ALL_COUNTS,
13+
DUPE_CANCELED_CRAWLS,
1314
DUPE_UNCOMMITTED,
1415
CrawlStatus,
1516
SkippedReason,
@@ -250,9 +251,12 @@ export class RedisDedupeIndex {
250251

251252
noremove = "noremove:" + Date.now();
252253

253-
constructor(dedupeRedis: Redis, crawlId: string) {
254+
autoCommit = false;
255+
256+
constructor(dedupeRedis: Redis, crawlId: string, autoCommit: boolean) {
254257
this.dedupeRedis = dedupeRedis;
255258
this.crawlId = crawlId;
259+
this.autoCommit = autoCommit;
256260
}
257261

258262
// DEDUPE SOURCE WACZ (to track dependencies)
@@ -384,20 +388,34 @@ export class RedisDedupeIndex {
384388
logger.debug("Crawl counts added to allcounts", {}, "dedupe");
385389
}
386390

387-
// ADD UNCOMITTED CRAWL
388-
async addUncommited() {
391+
// ADD UNCOMMITTED CRAWL
392+
async addCrawlForDedupe() {
389393
if (this.crawlId) {
390-
await this.dedupeRedis.sadd(DUPE_UNCOMMITTED, this.crawlId);
394+
// if autoCommit, add directly to all crawls list
395+
if (this.autoCommit) {
396+
await this.dedupeRedis.sadd(DUPE_ALL_CRAWLS, this.crawlId);
397+
} else {
398+
await this.dedupeRedis.sadd(DUPE_UNCOMMITTED, this.crawlId);
399+
}
391400
}
392401
}
393402

394403
// CLEAR UNCOMMITTED
395-
async clearUncommitted(crawlId?: string) {
404+
async clearUncommitted(crawlId?: string): Promise<boolean> {
405+
// won't be in the uncommitted list as auto-committing
406+
if (this.autoCommit) {
407+
return false;
408+
}
409+
396410
crawlId ||= this.crawlId;
397411
if (crawlId) {
398-
await this.dedupeRedis.srem(DUPE_UNCOMMITTED, crawlId);
399-
await this.deleteCrawlDedupeKeys(crawlId);
412+
if ((await this.dedupeRedis.srem(DUPE_UNCOMMITTED, crawlId)) > 0) {
413+
await this.deleteCrawlDedupeKeys(crawlId);
414+
return true;
415+
}
400416
}
417+
418+
return false;
401419
}
402420

403421
async clearAllUncommitted() {
@@ -458,11 +476,21 @@ export class RedisDedupeIndex {
458476
if (!origRecSize) {
459477
const { key, val } = this.getHashValue(hash, url, date, size);
460478
pipe.hsetnx(rootKey, key, val);
479+
480+
if (this.autoCommit) {
481+
pipe.hsetnx(DUPE_ALL_HASH_KEY, key, this.crawlId);
482+
}
461483
}
462484
this.incrTotalUrls(pipe, statsKey);
485+
if (this.autoCommit) {
486+
this.incrTotalUrls(pipe, DUPE_ALL_COUNTS);
487+
}
463488

464489
if (origRecSize) {
465490
this.incrDeduped(pipe, statsKey, origRecSize - size);
491+
if (this.autoCommit) {
492+
this.incrDeduped(pipe, DUPE_ALL_COUNTS, origRecSize - size);
493+
}
466494
}
467495

468496
await pipe.exec();
@@ -690,21 +718,45 @@ export class RedisDedupeIndex {
690718

691719
async purgeUnusedCrawls() {
692720
const noRemoveSet = new Set<string>(
693-
await this.dedupeRedis.smembers(this.noremove),
721+
await this.dedupeRedis.sdiff(this.noremove, DUPE_CANCELED_CRAWLS),
694722
);
695723

696724
await this.clearAndReadd(noRemoveSet);
697725

698726
await this.dedupeRedis.del(this.noremove);
727+
await this.dedupeRedis.del(DUPE_CANCELED_CRAWLS);
728+
}
729+
730+
async markCanceledCrawl(removeCrawlId: string) {
731+
if (!(await this.dedupeRedis.sismember(DUPE_ALL_CRAWLS, removeCrawlId))) {
732+
return false;
733+
}
734+
735+
await this.dedupeRedis.sadd(DUPE_CANCELED_CRAWLS, removeCrawlId);
736+
737+
await this.countUnusedCrawls();
738+
739+
return true;
699740
}
700741

701742
async countUnusedCrawls() {
702-
const removable = await this.dedupeRedis.sdiff(
703-
DUPE_ALL_CRAWLS,
704-
this.noremove,
705-
);
743+
const removeListCanceled =
744+
await this.dedupeRedis.smembers(DUPE_CANCELED_CRAWLS);
706745

707-
await this.dedupeRedis.del(this.noremove);
746+
let removable: Set<string>;
747+
748+
if (await this.dedupeRedis.scard(this.noremove)) {
749+
const removeList = await this.dedupeRedis.sdiff(
750+
DUPE_ALL_CRAWLS,
751+
this.noremove,
752+
);
753+
754+
await this.dedupeRedis.del(this.noremove);
755+
756+
removable = new Set<string>([...removeList, ...removeListCanceled]);
757+
} else {
758+
removable = new Set<string>(removeListCanceled);
759+
}
708760

709761
let total = 0;
710762

@@ -722,7 +774,7 @@ export class RedisDedupeIndex {
722774
await this.dedupeRedis.hset(
723775
DUPE_ALL_COUNTS,
724776
"removedCrawls",
725-
removable.length,
777+
removable.size,
726778
);
727779
await this.dedupeRedis.hset(DUPE_ALL_COUNTS, "removedCrawlSize", total);
728780
}
@@ -817,11 +869,12 @@ export class RedisCrawlState extends RedisDedupeIndex {
817869
uid: string,
818870
maxRetries?: number,
819871
dedupeRedis?: Redis,
872+
dedupeAutoCommit = false,
820873
rateLimitTTL?: number,
821874
rateLimitInterruptCount?: number,
822875
rateLimitMaxRetries?: number,
823876
) {
824-
super(dedupeRedis || redis, key);
877+
super(dedupeRedis || redis, key, dedupeAutoCommit);
825878
this.redis = redis;
826879

827880
this.uid = uid;

0 commit comments

Comments
 (0)