ADR 0016: Crawl until N new posts, not N sources¶
- Status: Accepted
- Date: 2026-07-03
- Deciders: jobhub-architect, David R H
- Affects: crawler-service (story #263, sub-issue #264)
Context¶
crawler-service is Hexagonal. CrawlerService.crawlBatch(int limit) today loops
while (count < limit), incrementing count once per TARGET crawled by crawlNext().
limit is a target/source count (crawler.crawl.batch-size, default 10; prod 70). A run
stops when it has visited limit targets, when no target is available
(PullTargetRepository.findNextAvailableAndLock() returns empty, respecting the
post-success 1h cooldown), or on trigger cancellation.
Story #263 changes the goal: a run must keep crawling until it has collected at least N genuinely NEW job posts (target N=100, "regardless of source"), OR there are no more sources to crawl. This applies to BOTH the scheduled crawl and the trigger/REST crawl.
The genuinely-new set already exists at runtime: doCrawl() to persistJobs() computes
newByUrl (postings not matched by content-hash then URL) but currently discards its size.
So the count is runtime-computed: no schema change, no api-contracts change (the /crawl
endpoint is hand-written JAX-RS; there is no crawler-service.yaml).
Constraints: the domain must stay framework-free (Hexagonal, zero JPA/CDI below the ports),
and each crawlNext() must keep its own @Transactional boundary so every target commits
independently. The accumulator therefore lives in the caller loop, not inside a transaction.
Decision¶
We will make a crawl run terminate on cumulative NEW posts, not on targets visited, in both entry points, with whole-source granularity and a safety cap on targets visited.
- New-count flow (domain-internal, framework-free).
persistJobs(List<JobPost>)returnsint(the number of genuinely-new postings inserted this pull).doCrawl(PullTarget)surfaces that count. It currently returnsPullResult; add a small immutable domain valueCrawlOutcome { PullResult result, int newPosts }(underdomain/model/,@Getter @Builder, no annotations) and return it. On a failed pull,newPosts = 0. The REST single-targetcrawl(UUID)path ignores the count.crawlNext()returns the per-step new count instead of a boolean. Change its signature toOptional<Integer> crawlNext():Optional.of(newPosts)when a target was crawled,Optional.empty()when no target was available. This keeps the "no target left" signal distinct from "crawled but produced 0 new posts".crawlNext()keeps@Transactional.-
crawlBatch(...)owns anint newPostsaccumulator in the loop body. Because the accumulator is a local variable in the non-transactionalcrawlBatchmethod, it survives naturally across the separatecrawlNext()transactions; each committed transaction returns its count up to the loop, which adds it in. -
Stop rule for
crawlBatch(int minNewPosts, UUID triggerRequestId). Loop: - if
triggerRequestId != nulland cancel requested -> stop,cancelled = true. - if targets visited
>= maxTargetsPerRun(safety cap, see below) -> stop. - call
crawlNext(); if empty (no available target) -> stop. - else add its count to
newPosts, incrementtargetsVisited. -
after adding, if
newPosts >= minNewPosts-> stop. ThenewPosts >= minNewPostscheck is evaluated only between wholecrawlNext()steps, so a source is always crawled in full: the run stops AFTER the source that pushed the cumulative total over the target, never mid-source. -
Config.
- New key
crawler.crawl.min-new-posts, default100. This is the per-run new-post target and is what the scheduler and trigger pass tocrawlBatch. - New key
crawler.crawl.max-targets-per-run, default200. Safety cap on targets visited per run (see Consequences for the rationale). Bounds outbound HTTP work when few new posts exist. crawler.crawl.batch-size(targets) is removed as the driver ofcrawlBatch.CrawlerSchedulerandTriggerRequestSchedulernow injectmin-new-posts, notbatch-size. (The key may be deleted from properties files; it has no other consumer.)crawler.crawl.max-batch-size(the old validation bound on theintargument) is repurposed and renamed conceptually intomax-targets-per-runabove. ThecrawlBatchargument validation changes to guardmin-new-posts:if (minNewPosts < 1) throw ValidationException. There is no upper bound on the target count itself (the run is bounded by cooldown-exhaustion and bymax-targets-per-run).-
REST
POST /crawl?limit=is reinterpreted:limitbecomes the min-new-posts target for that ad-hoc run (default changed from10to the configuredmin-new-posts, i.e.100). It is validated>= 1only. The old "targets" meaning is dropped. Themax-targets-per-runsafety cap still applies to REST runs. -
CrawlBatchResultshape (frozen). Addint newPosts. Keepcrawledrenamed in meaning to "targets visited" (kept for observability; field name stayscrawledto avoid churn, documented as targets-visited).isEmpty()returnsnewPosts == 0 && crawled == 0so the RESTNO_CONTENTpath fires only when nothing at all happened. New shape:{ int crawled /*targets visited*/, int newPosts, boolean hasMore, boolean cancelled }. -
No
db/initmigration and no api-contracts YAML change. The new count is runtime-computed;CrawlBatchResultis an internal domain/JSON DTO, not contract-owned.
Consequences¶
- Positive: crawl output is measured by the thing that matters (new postings), consistent across scheduled and triggered runs; no schema or contract change; layering preserved.
- Positive: whole-source granularity keeps each source's cooldown accounting correct and avoids half-crawled sources.
- Negative / cost: a run can now visit many sources when new posts are scarce. The
max-targets-per-runcap (default 200) bounds that: without it a low-yield run would walk every available source each cron tick, hammering boards and spending LLM/HTTP budget for little gain. 200 is comfortably above the current source count while still a hard ceiling. - Negative / cost: the
crawlNext()signature change (boolean->Optional<Integer>) and theCrawlBatchResultfield change touch the unit + component tests; those must be updated. - Follow-ups: PDA writes the functional spec; QAE updates test cases (stop-on-target,
stop-on-exhaustion, stop-on-cap, whole-source-not-mid-source, cancellation); backend dev
implements. Update summary strings (below) and remove
crawler.crawl.batch-sizefromapplication-dev.properties/application-prod.properties.
Alternatives considered¶
- Keep
batch-size(targets) and addmin-new-postsas a second stop condition — rejected: two overlapping bounds with different units is confusing; the story wants the new-post target to be the primary driver, with only a safety cap in target units. - No safety cap (rely only on cooldown-exhaustion to bound a run) — rejected: a low-yield period would make every scheduled run traverse all sources, defeating the point of polite, cooldown-paced crawling and spiking outbound HTTP.
- Check the target mid-source (stop as soon as
newPosts >= targetwithin a pull) — rejected: a pull is atomic at the client level; splitting it complicates cooldown/state and gains nothing. Whole-source granularity is simpler and correct. - Move
CrawlBatchResultinto an api-contracts YAML — rejected:/crawlis an internal hand-written endpoint with no spec; contract-first does not apply to it.