Adding a job type
Job execution: JobScheduler (services/scheduler/service.py) spawns daemon worker threads —
N general runners (count from ctx.config.crawler.runner_concurrency), one dedicated
artifact-only worker, one Scrubber loop, and one stale-job reset loop. JobRunner
(runner.py) claims jobs; handlers in scheduler/handlers/ execute them.
The recipe, end to end
- Enum — add a member to
JobType(IntEnum) in lncrawl/enums.py (values are grouped by
domain; pick a free integer near its siblings). Enums re-export automatically via
dao/__init__.py.
- Postgres enum-sync migration — required because enum columns are stored by name
and are native
ENUM types on Postgres (SQLite stores VARCHAR, no DDL needed). Model it
on an existing sync_* migration in migrations/versions/: raw op.execute DDL guarded
by op.get_context().dialect.name == 'postgresql', no-op otherwise. Alembic autogenerate
will NOT produce this.
- JobService factory — add a
def my_job(self, user, ..., *, parent_id=None, depends_on=None, **data) -> Job method in services/jobs/service.py that funnels into
self._create(...). _create applies tier limits (ctx.tier.max_active_jobs), sets
priority=ctx.tier.job_priority(user), resolves domain, and rolls total up the
ancestor chain. If the job hits one source domain and must respect the
one-running-job-per-domain rule, add its type to _DOMAIN_JOB_TYPES and make sure the
job data lets _resolve_domain work (domain/url/novel_id/chapter_id).
- Handler — create
services/scheduler/handlers/my_job.py:
BaseHandler for a leaf job; BatchHandler when the job spawns child jobs.
- Implement
can_activate(job) (match on job.type) and run().
- Register it in
_HANDLER_REGISTRY in handlers/__init__.py — before
FallbackHandler, which must stay last (it fails any unmatched job).
job_title — add a branch in Job.job_title (dao/job.py) so the UI/logs render a
label.
- API endpoint (usually) — a
POST /api/job/create/<hyphenated-kind> route in
server/api/jobs.py with Security(ensure_user) and a Pydantic body model from
server/models/, calling the JobService factory.
Handler contract (handlers/_base.py)
- Constructor gets
(job, signal); process() wraps run() with logging and terminal-state
handling: AbortedException → silent stop, HandlerException → failure with its message,
any other exception → generic failure.
- Helpers
_set_running(), _increment(), _set_success(), _set_failure(),
_set_extra(**values) commit their own DB session and call
ctx.job_notifier.notify(...) — email notifications come for free; don't call the
notifier manually.
- Idiom inside
run(): if not self.job.is_running: self._set_running(), and poll
if self.signal.is_set(): raise AbortedException() between units of work — cancellation is
cooperative; nothing kills the thread.
BatchHandler.run() is re-entered repeatedly until all children are done (its
process() marks success only when every child is_done). run() must be idempotent —
guard against re-creating children (see the added_* bookkeeping in existing batch
handlers like chapter_batch.py).
Scheduling model (what to know when debugging)
- Find-and-claim happens in
JobRunner._claim_next() under a module-level EventLock; the
actual run_job() executes outside the lock. Fairness: skips users and domains that
already have a running claim, then falls back without fairness if nothing matched.
_pending selects PENDING (or just-started RUNNING with no progress) jobs whose
depends_on is done, ordered by priority then age. The artifact worker sees only
JobType.ARTIFACT; general runners see everything else.
- Cancel = set the claim's
Event (in-memory) + mark the job and its descendants CANCELED in
DB (ctx.jobs.cancel). reset_stale re-signals claims older than the configured max age.
- Progress (
done/failed/total) rolls up ancestor chains via recursive CTEs
(services/jobs/utils.py); a parent auto-completes when done == total.
- The
Scrubber deletes old jobs/tokens/users/activities on its own loop — check it before
assuming rows persist forever.
New email notification (only for a new email type)
Subclass MailNotification (services/notifications/_base.py), add a NotificationItem
enum member, and register the class in the NOTIFICATIONS dict in
services/notifications/__init__.py. Delivery is deduped via job.extra["email_sent"] and
sent on a background TaskManager.
1---2name: add-job-type3description: Add or modify a background job type in lncrawl — JobType enum, JobService factory, handler class, registry, notifications. Use when creating a new job kind, changing a handler, or debugging job scheduling/cancellation.4---56# Adding a job type78Job execution: `JobScheduler` (services/scheduler/service.py) spawns daemon worker threads —9N general runners (count from `ctx.config.crawler.runner_concurrency`), one dedicated10**artifact-only** worker, one `Scrubber` loop, and one stale-job reset loop. `JobRunner`11(runner.py) claims jobs; handlers in `scheduler/handlers/` execute them.1213## The recipe, end to end14151. **Enum** — add a member to `JobType(IntEnum)` in `lncrawl/enums.py` (values are grouped by16 domain; pick a free integer near its siblings). Enums re-export automatically via17 `dao/__init__.py`.182. **Postgres enum-sync migration** — required because enum columns are stored **by name**19 and are native `ENUM` types on Postgres (SQLite stores VARCHAR, no DDL needed). Model it20 on an existing `sync_*` migration in `migrations/versions/`: raw `op.execute` DDL guarded21 by `op.get_context().dialect.name == 'postgresql'`, no-op otherwise. Alembic autogenerate22 will NOT produce this.233. **JobService factory** — add a `def my_job(self, user, ..., *, parent_id=None,24 depends_on=None, **data) -> Job` method in `services/jobs/service.py` that funnels into25 `self._create(...)`. `_create` applies tier limits (`ctx.tier.max_active_jobs`), sets26 `priority=ctx.tier.job_priority(user)`, resolves `domain`, and rolls `total` up the27 ancestor chain. If the job hits one source domain and must respect the28 one-running-job-per-domain rule, add its type to `_DOMAIN_JOB_TYPES` and make sure the29 job data lets `_resolve_domain` work (`domain`/`url`/`novel_id`/`chapter_id`).304. **Handler** — create `services/scheduler/handlers/my_job.py`:31 - `BaseHandler` for a leaf job; `BatchHandler` when the job spawns child jobs.32 - Implement `can_activate(job)` (match on `job.type`) and `run()`.33 - Register it in `_HANDLER_REGISTRY` in `handlers/__init__.py` — **before**34 `FallbackHandler`, which must stay last (it fails any unmatched job).355. **`job_title`** — add a branch in `Job.job_title` (`dao/job.py`) so the UI/logs render a36 label.376. **API endpoint** (usually) — a `POST /api/job/create/<hyphenated-kind>` route in38 `server/api/jobs.py` with `Security(ensure_user)` and a Pydantic body model from39 `server/models/`, calling the JobService factory.4041## Handler contract (`handlers/_base.py`)4243- Constructor gets `(job, signal)`; `process()` wraps `run()` with logging and terminal-state44 handling: `AbortedException` → silent stop, `HandlerException` → failure with its message,45 any other exception → generic failure.46- Helpers `_set_running()`, `_increment()`, `_set_success()`, `_set_failure()`,47 `_set_extra(**values)` commit their own DB session **and call48 `ctx.job_notifier.notify(...)`** — email notifications come for free; don't call the49 notifier manually.50- Idiom inside `run()`: `if not self.job.is_running: self._set_running()`, and poll51 `if self.signal.is_set(): raise AbortedException()` between units of work — cancellation is52 **cooperative**; nothing kills the thread.53- **`BatchHandler.run()` is re-entered repeatedly** until all children are done (its54 `process()` marks success only when every child `is_done`). `run()` must be idempotent —55 guard against re-creating children (see the `added_*` bookkeeping in existing batch56 handlers like `chapter_batch.py`).5758## Scheduling model (what to know when debugging)5960- Find-and-claim happens in `JobRunner._claim_next()` under a module-level `EventLock`; the61 actual `run_job()` executes outside the lock. Fairness: skips users and domains that62 already have a running claim, then falls back without fairness if nothing matched.63- `_pending` selects PENDING (or just-started RUNNING with no progress) jobs whose64 `depends_on` is done, ordered by priority then age. The artifact worker sees only65 `JobType.ARTIFACT`; general runners see everything else.66- Cancel = set the claim's `Event` (in-memory) + mark the job and its descendants CANCELED in67 DB (`ctx.jobs.cancel`). `reset_stale` re-signals claims older than the configured max age.68- Progress (`done/failed/total`) rolls up ancestor chains via recursive CTEs69 (`services/jobs/utils.py`); a parent auto-completes when `done == total`.70- The `Scrubber` deletes old jobs/tokens/users/activities on its own loop — check it before71 assuming rows persist forever.7273## New email notification (only for a new email *type*)7475Subclass `MailNotification` (`services/notifications/_base.py`), add a `NotificationItem`76enum member, and register the class in the `NOTIFICATIONS` dict in77`services/notifications/__init__.py`. Delivery is deduped via `job.extra["email_sent"]` and78sent on a background `TaskManager`.