add-job-type
GitHub指导在lncrawl项目中新增或修改后台作业类型的完整流程,涵盖枚举定义、Postgres迁移、工厂方法、处理器注册及API端点创建。
Trigger Scenarios
Install
npx skills add lncrawl/lightnovel-crawler --skill add-job-type -g -y
SKILL.md
Frontmatter
{
"name": "add-job-type",
"description": "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."
}
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)inlncrawl/enums.py(values are grouped by domain; pick a free integer near its siblings). Enums re-export automatically viadao/__init__.py. - Postgres enum-sync migration — required because enum columns are stored by name
and are native
ENUMtypes on Postgres (SQLite stores VARCHAR, no DDL needed). Model it on an existingsync_*migration inmigrations/versions/: rawop.executeDDL guarded byop.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) -> Jobmethod inservices/jobs/service.pythat funnels intoself._create(...)._createapplies tier limits (ctx.tier.max_active_jobs), setspriority=ctx.tier.job_priority(user), resolvesdomain, and rollstotalup 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_TYPESand make sure the job data lets_resolve_domainwork (domain/url/novel_id/chapter_id). - Handler — create
services/scheduler/handlers/my_job.py:BaseHandlerfor a leaf job;BatchHandlerwhen the job spawns child jobs.- Implement
can_activate(job)(match onjob.type) andrun(). - Register it in
_HANDLER_REGISTRYinhandlers/__init__.py— beforeFallbackHandler, which must stay last (it fails any unmatched job).
job_title— add a branch inJob.job_title(dao/job.py) so the UI/logs render a label.- API endpoint (usually) — a
POST /api/job/create/<hyphenated-kind>route inserver/api/jobs.pywithSecurity(ensure_user)and a Pydantic body model fromserver/models/, calling the JobService factory.
Handler contract (handlers/_base.py)
- Constructor gets
(job, signal);process()wrapsrun()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 callctx.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 pollif 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 (itsprocess()marks success only when every childis_done).run()must be idempotent — guard against re-creating children (see theadded_*bookkeeping in existing batch handlers likechapter_batch.py).
Scheduling model (what to know when debugging)
- Find-and-claim happens in
JobRunner._claim_next()under a module-levelEventLock; the actualrun_job()executes outside the lock. Fairness: skips users and domains that already have a running claim, then falls back without fairness if nothing matched. _pendingselects PENDING (or just-started RUNNING with no progress) jobs whosedepends_onis done, ordered by priority then age. The artifact worker sees onlyJobType.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_stalere-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 whendone == total. - The
Scrubberdeletes 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.
Version History
- b76d44a Current 2026-07-25 09:00


