ADR 0020: Facet-response cache invalidated by a crawl-data generation stamp¶
- Status: Accepted
- Date: 2026-07-21
- Deciders: jobhub-architect (story #332 / ticket #399)
- Affects: job-service, db/init (index 049). No api-contracts change; no crawler-service change.
Context¶
GET /jobs/facets (job-service) computes exclude-own-dimension aggregates for five dimensions
(companies, locations, languages, employmentTypes, careerLevels) plus a compensation
min/max range. Each dimension is a filtered GROUP BY / COUNT over crawler.job_post
(with the correlated location EXISTS and array-overlap predicates from ADR 0017), recomputed
on every filter change. The UI hits this endpoint on each keystroke/filter toggle, so a broad
listing recomputes six aggregate passes per interaction.
The underlying data is near-static between crawls: crawler.job_post only mutates when the
crawler writes. So the same filter combo yields the same facet payload until the crawler next
changes the table, which makes it a strong caching candidate, the same reasoning behind the
short-TTL count cache in ADR 0018 (CountCache / CountCacheKey / CountCacheEntry).
Constraints in scope:
- job-service is Hexagonal. The cache lives in domain/service/ (like CountCache, an
@ApplicationScoped domain service); the domain model stays framework-free; the DB read
goes behind the existing JobPostRepository out-port.
- Contract-first: GET /jobs/facets response shape must not change (pure internal caching).
- Schema is owned by db/init SQL; Hibernate is validate in prod.
- No production code whose only purpose is testing: freshness/stamp logic takes now and the
read stamp as parameters (the CountCacheEntry injected-now precedent).
The crux is invalidation: how job-service learns a crawl changed the data. Two candidate
signals were on the table (radar hint: "Touches: job-service, crawler-service"):
(a) job-service reads a cheap generation stamp from the crawler schema, TTL-guarded; or
(b) crawler pushes to a new internal X-Service-Key job-service endpoint on run completion.
A decisive finding from reading the crawler write paths: facet-affecting data changes are not
all captured by any crawl-completion timestamp. applyEnrichment()
(crawler ... JobPostPanacheRepository) fills exactly the facet dimensions (employment_type,
career_level, languages, city/country, compensation_min/max) in a separate async
pass, bumping enriched_at but not last_seen_at and not
pull_target.last_successful_pull. normalizeLanguagesBatch() (maintenance) rewrites the
languages facet dimension and bumps no timestamp at all. So last_successful_pull,
pull_log.pulled_at and trigger_request.finished_at all under-report facet freshness (they
track pulls/admin runs, not enrichment or maintenance), and "run completion" is not a single
well-defined moment to push on.
Decision¶
We will add a facet-response cache in job-service, mirroring CountCache, invalidated by a
generation stamp read cheaply from crawler.job_post and TTL-guarded (option a). No
crawler-service change, no new HTTP endpoint, no contract change.
Cache (domain/service, mirrors CountCache)¶
FacetCacheKey(record) withfrom(JobSearchQuery)covering the filter combo only:keyword, locations, languages, companies, employmentTypes, careerLevels, compensationMin, compensationMax, postedWithin.page/size/sortare excluded (facets are a property of which postings match, not of how a page is sliced), identical rationale toCountCacheKey.FacetCacheEntry(record)= (JobFacets value, Instant cachedAt, long generation)with a pureisFresh(Instant now, Duration ttl)(injectednow, no clock seam).FacetCache(@ApplicationScoped) bounded LRU via access-orderedLinkedHashMap+removeEldestEntry,Collections.synchronizedMap, config-driven enable/ttl/max-size, exactly likeCountCache.get(FacetCacheKey key, long generation)returns the value only when an entry exists andentry.generation == generationand it is fresh;put(key, value, generation)stamps the current generation.
Generation stamp (the invalidation signal)¶
- New out-port method on
JobPostRepository, e.g.long facetDataVersion(), implemented inJobPostPanacheRepositoryas a single read:SELECT COALESCE(EXTRACT(EPOCH FROM GREATEST(MAX(last_seen_at), MAX(enriched_at))) * 1000, 0)FROM crawler.job_post(empty table to0). This reads directly from the data the facets are computed from, so it captures inserts + re-crawls (last_seen_at) and enrichment (enriched_at), which a pull/trigger timestamp would miss. - A
CrawlGenerationStamp(@ApplicationScoped, domain/service) TTL-guards the read: it caches the last stamp forjob.search.facets.stamp.ttland only re-queries the DB when that window elapses, so facet traffic never becomes a per-requestMAXstorm (the "cheap version-stamp read, TTL-guarded" precedent from ADR 0018 / #331/#383). The "should I re-read?" test is a pure function of(lastReadInstant, now, ttl)so it is unit-testable without a real clock. On a DB read error it returns the last-known stamp (fail-soft: serve within the entry TTL rather than fail the endpoint).
JobService.getFacets wiring¶
Read generation = crawlGenerationStamp.current(), build FacetCacheKey.from(query), return a
FacetCache.get(key, generation) hit if present, else compute jobPostRepository.facets(query),
put(key, result, generation), return it. When the stamp changes, all prior-generation entries
stop matching and age out by LRU; the entry TTL is the second bound (below).
Config keys (job.search.facets.* prefix)¶
| Key | Default | Meaning |
|---|---|---|
job.search.facets.cache.enabled |
true |
Toggle the facet cache. |
job.search.facets.cache.ttl |
PT60S |
Per-entry safety-net TTL (see Consequences: covers stamp-blind mutations). |
job.search.facets.cache.max-size |
500 |
Max distinct filter-combos cached (LRU); facet payloads are larger than counts, so a smaller cap than count's 1000. |
job.search.facets.stamp.ttl |
PT10S |
How often the crawl generation stamp is re-read; also the post-crawl staleness window before invalidation is observed. |
Migration¶
db/init/049-job-post-facet-stamp-index.sql: two indexes on crawler.job_post to make the
stamp MAX an index backward-scan instead of a table aggregate:
idx_job_post_last_seen_at (last_seen_at DESC) and
idx_job_post_enriched_at (enriched_at DESC). Pure DDL, authored inside the job-service ticket;
no new grant (job_user already holds SELECT on crawler.job_post), no crawler code.
Consequences¶
- Positive: repeated/keystroke facet requests for a filter combo are served from memory until the crawler next changes the data; the six aggregate passes collapse to one compute per (filter combo x generation).
- Positive: single service, one developer ticket. No crawler change, no
X-Service-Key/.envwiring, no new cross-service coupling, and it is correct under horizontal scaling (every job-service instance reads the stamp independently; a push would only invalidate one instance). - Positive: stamp reads
job_postdirectly, so enrichment (the async pass that fills the facet dimensions) invalidates correctly, which alast_successful_pull/ trigger-completion signal would not. - Negative / cost: bounded staleness window of
stamp.ttl(default 10s) between a crawl write and the cache noticing, acceptable for filter-count chips. - Negative / cost: the
MAX-timestamp stamp cannot see facet-affecting mutations that bump no timestamp (e.g.normalizeLanguagesBatchmaintenance, or a future hard-delete/prune). The per-entrycache.ttl(default 60s) is the deliberate backstop that expires such stale entries; this is why the design keeps both a generation stamp (fast common-path invalidation) and an entry TTL (worst-case bound), not one or the other. - Negative / cost: a new config surface, one bounded in-memory cache, one new out-port method, and one DDL migration to maintain in job-service.
- Follow-ups: backend #NNN implements
FacetCacheKey/FacetCacheEntry/FacetCache+CrawlGenerationStamp+ theJobPostRepository.facetDataVersion()port method + migration 049, and wiresJobService.getFacets. QAE covers: cache hit for a repeated filter combo, miss on a changed filter field, invalidation when the stamp advances (seed a newlast_seen_at/enriched_at), and theenabled=falsebypass. No frontend or contract work.
Alternatives considered¶
- HTTP push from crawler to an internal
X-Service-Keyjob-service endpoint (option b). Rejected: touches two services plus.env/compose key wiring; creates a new crawler to job-service dependency (reversing today's job reads crawler-DB direction); has no single "run completion" moment to fire on because enrichment and maintenance are separate async passes, so it would need multiple push points and still miss timestamp-less maintenance writes; and it only invalidates the one instance it hits, so it is less correct under horizontal scaling than a per-instance DB stamp read. Zero-staleness is its only edge, and a 10s stamp TTL makes that moot for filter chips. The radar's "Touches: crawler-service" hint is not binding: the enrichment analysis makes the single-service DB read strictly more correct. - Stamp from
pull_target.last_successful_pull/pull_log.pulled_at/trigger_request.finished_at. Rejected: all track pulls or admin-triggered runs, none capture the async enrichment pass that fills the facet dimensions, so facets would stay stale while enrichment backfillsemployment_type/career_level/languages/location/compensation. - Fold the stamp into the cache key (instead of a per-entry generation check). Equivalent
correctness, rejected on hygiene: on every stamp change the whole keyspace turns to dead entries
that only LRU reclaims; a per-entry
generationfield lets agetreject stale entries explicitly and keeps the key a pure function of the filter combo (matchingCountCacheKey). - Materialise facets per filter combo (trigger/table-maintained). Rejected as in ADR 0018: the free-text + multi-select filter space is effectively unbounded; impractical to precompute.