job-lifecycle-validity-and-cleanup

Job Lifecycle: Validity, Expiry and Cleanup

A job posting is never assumed to still be real. Every time one is served, it has to prove it is still valid — and how it proves that depends on where it came from. The system splits postings by origin type and runs a different check for each. When a check fails, the posting is not flagged or hidden; it is hard-deleted on the spot, and the caller is told the job is gone.

Two kinds of provenance, two rules

JobValidityService is a thin router built on the Strategy pattern. It holds a map from origin type to a JobValidator, and ensureJobIsValid simply looks up the validator for the job's originType and delegates. Two validators are registered at construction — "direct" and "aggregated" — and any origin type with no registered validator is treated as valid by default, so new provenance kinds can be added without touching the router. A registerValidator method lets callers plug in more validators the same way (open for extension, closed for modification).

Direct postings carry their own expiry. DirectJobValidator compares the posting's expiresAt timestamp against now: if expiresAt is still in the future, the job passes untouched; if it has passed, the validator deletes the job, notifies the change handler, and throws JobInvalidError.expired. The rule is entirely local — no network call, just a date comparison.

Aggregated postings were pulled from someone else's board, so their validity is defined externally: the job is valid only as long as its source URL is still reachable. AggregatedJobValidator first requires a sourceUrl at all — a posting with none is deleted and reported unreachable immediately. Otherwise it decides whether a fresh check is due.

Rechecking without hammering the source

Re-verifying every aggregated posting on every request would mean a network round trip per view, so the validator throttles itself. shouldRecheck returns true only when one of three things holds: the source was never checked, the last check was at least recheckMinutes ago, or the last recorded status was not healthy (a 2xx). If none apply, the cached verdict stands and no request goes out.

When a recheck is due, the validator calls the source verifier, then persists the outcome — sourceLastCheckedAt and sourceLastStatus — via updateValidity, so the throttle has fresh state for next time. If the verifier reports the source is not OK, the job is deleted and JobInvalidError.unreachable is thrown.

SourceVerificationService is a one-method port: verify(url) returns { ok, statusCode? }. The HTTP implementation issues a HEAD request (cheap — headers only, no body) behind an AbortController with a short timeout, and treats a 2xx or 3xx response as OK. Any thrown error — timeout, DNS failure, refused connection — collapses to { ok: false } with no status code, so an unreachable source is indistinguishable from a dead one, exactly as intended. (Note the small asymmetry: the verifier calls 2xx–3xx "OK", while the validator's recheck trigger only counts 2xx as "healthy" — so a 3xx-served posting survives but is re-checked more eagerly.)

Deletion is the same act everywhere

Both validators converge on one shape when a job is invalid: delete it from the repository, then call the change handler's jobDeleted hook. That hook is the seam through which downstream consumers — caches, search indexes — learn the row is gone, so a validity failure fans out instead of leaving stale copies behind. The notifier is optional; when absent, the deletion still happens, only the fan-out is skipped.

Sweeping the long tail

Per-request validation removes jobs the moment someone touches an expired or dead one, but jobs nobody ever looks at again would linger forever. JobCleanupService is the janitor for that long tail. triggerCleanup is called opportunistically and is non-blocking — it records the time, kicks off the sweep in the background, and returns at once; errors are logged, never surfaced to the caller. It also self-throttles to at most once per hour, so hot paths can call it freely.

The background sweep deletes jobs that expired more than a year ago, and, if a report repository is present, handled job reports older than ninety days. Each pass logs how many rows it removed. This is deliberately generous: recent history is kept around, and only the genuinely stale tail is reclaimed.

Why it is shaped this way

The design keeps one invariant: an invalid job is a deleted job, not a marked one. There is no "expired but still visible" state to reason about downstream. Validity is checked lazily, at the point of use, so the freshest possible verdict is applied exactly when it matters; the hourly cleanup exists only to collect what lazy checks never revisit. Splitting the rule by provenance means each kind of job is judged by the only signal that actually defines it — a self-declared expiry for direct postings, live reachability for aggregated ones.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →