nw.jobs#
nw.jobs — a project-scoped async job facade over au.
A “job” is one long, cancellable unit of render work (a full-auto journey, a
single panel.animate, an assemble_animatic pass, …). It has a durable
id, a persistent terminal state, live progress + ETA, a cost, and a cancel
entry keyed by that id — everything a task tray needs and none of which a bare
HTTP request provides.
This module is the only place async substance lives for the render layer:
au supplies the submit → poll → result skeleton and the durable store;
nw.jobs adds the five render-domain concerns au has no data model for —
progress %, ETA, human label/kind, idempotency (dedup), and cost — plus the
5-state normalization, context-capture so a job outlives its request, and the
active-jobs index that makes membership meaningful. Consumers (reelee’s
/api/jobs closures) stay thin: they call enqueue() / estimate()
/ list_jobs() / get_job() / cancel_job() / to_dict()
and serialize the result.
Design decisions (from the nw.jobs-on-au design report,
misc/docs/research/async-job-manager-on-au.md):
**Backend =
au.ThreadBackend``**, not ``ProcessBackend. A fal render is I/O-bound (a blocking upstream wait), and the worker must share the live in-processProjectgraph and write events to the same channel the existing SSE tails. Process isolation buys no real fal cancel (fal still bills) while breaking the live-progress channel. Where bounded concurrent paid renders matter, swap inStdLibQueueBackend(use_processes=False)— a construction detail behind this facade.au store is SSOT for *status* only.
ThreadBackendoverwrites the store record with a bareComputationResultat start (RUNNING) and end (COMPLETED), carrying no metadata — so every job-semantic field lives in a per-project active-jobs index (adolmapping), whichaucannot clobber. The index is also the membership authority:au.FileSystemStoresynthesizesPENDINGfor a missing key, so store membership is meaningless.Idempotency: the au store key is the idempotency key (
sha256(project:kind:plan_hash-or-params)). A resubmit while a job with that key is live returns the existing job instead of launching a duplicate.Cancel is boundary-grained and race-proof.
cancel_jobflips the au record terminal and sets a durable should-cancel flag; thecancel_requestedflag is the authority for cancellation intent, so a job readscancelling→cancelledregardless of whether the still-runningThreadBackendthread later clobbers the store with COMPLETED.ETA is learned from observed wall-time, per
(model, operation, dur_bucket)with back-off, median (not mean), honest “estimating…” before enough history, and cache-hits excluded from learning.A cost never travels without its honesty flag.
JobCost.actual_usdis paired withactual_is_lower_bound, because an unpriceable call that actually billed contributes0.0to the sum — so a bare$0means either “nothing was spent” or “we do not know what was spent”, and a spend surface that cannot tell them apart shows the second as free.An estimate is re-quoted, never remembered. When
params["plan"]is supplied,estimate()andenqueue()price it throughnw.pricing.current_quote()at today’s rates rather than trusting acaller-supplied
estimated_usdfrozen at plan time — falaw’s rate tables move, and a stale figure under-quotes the run (nw#74). Descriptive only:cost_basisstays out ofplan_hash, so the idempotency key above is byte-identical to what it was before repricing existed, and a resumed render still dedups onto work already paid for.
All tunables are keyword-configurable via JobsConfig; defaults live at
the top of this module — no magic numbers below.
Module Attributes
Why a quote came back unknown when the plan itself was unreadable. |
|
|
Functions
|
Request cancellation. |
|
Enqueue a billable render as a background job. |
|
|
|
Dry-run cost gate without enqueueing. |
|
One job (projecting the au status + mirrored index metadata). |
|
Jobs for this project, newest first, optionally filtered by status. |
|
Predict the total render seconds for a job as |
|
This project's jobs without provisioning it. |
|
Serialize a |
Classes
|
Keyed, percentile duration learner — a cousin of |
|
Projected, JSON-serializable view of one job (see |
|
|
|
|
|
Tunables for the job manager. |
- class nw.jobs.DurationLearningMiddleware(*, durations, index, lock, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
Bases:
MiddlewareKeyed, percentile duration learner — a cousin of
au.MetricsMiddleware.Records the render wall-time under the job’s ETA-key candidates, so a cold specific key backs off to a warmer coarse one. The specific
learnedkeys get the whole-job sample. The coarseoutput_kindkey is **shared across operations** and means seconds per unit, so a job that has specific keys writes it only when it declaredparams["units"], and then withelapsed / units. Such a job of unknown multiplicity never touches the shared bucket: forgettingunitscosts a cold coarse bucket, never a corrupted one (nw#67 — a 4-render job used to write its 4-fold duration into theimagebucket single-image jobs read from). A job whose only key is the coarse one (no model/operation) keeps learning in it as before — it has nowhere else to learn. Such a job that really covers N units should still declareunits, or it writes its N-fold duration into the shared bucket, exactly as before nw#67. Two deliberate departures fromau’s built-in metrics:Self-timed (
time.monotonicinbefore_compute→after_compute) rather than readingresult.duration:ThreadBackendconstructs a fresh COMPLETEDComputationResultwhosecreated_atis the completion instant, soresult.durationis ~0 and useless. (Surfaced as anaufinding.)Cache-hits are never learned — a ~0s cache hit would drag the median to zero. The job’s
cachedflag (mirrored from acache_hitevent during the run) gates recording.
Its
_startmap doubles as the in-process liveness signal the stale reaper uses (a RUNNING au record whose key is not in_startand whosestarted_atis old is a dead worker).
- nw.jobs.ERROR_KIND_REFUSED = 'refused'#
Job.error_kindvalues — seeJob.error_kind.
- class nw.jobs.Job(job_id, kind, label, status, idempotency_key, params=<factory>, created_at=None, started_at=None, finished_at=None, queue_wait_s=None, elapsed_s=None, progress=<factory>, predicted_total_s=None, remaining_s=None, eta_ts=None, eta_s=None, pct=None, confidence=None, label_hint=None, eta_key=None, cost=<factory>, cached=False, worker_silent_s=None, worker_responsive=None, artifact_ref=None, result=None, error=None, error_kind=None, run_id=None, last_event_id=None)[source]#
Bases:
objectProjected, JSON-serializable view of one job (see
to_dict()).- error_kind: str | None = None#
"refused"(the op raisednw.GenreOpRefused— a deliberate refusal with a message for the person),"cancelled"(stopped on request, or the op raisednw.GenreOpCancelled),"crashed"(anything else — a bug, or a worker that stopped beating).Nonewhile running and on success.errorkeeps the text either way.- Type:
Why a job did not succeed, for a screen to say so
- worker_responsive: bool | None = None#
Whether the worker is provably still alive, by the reaper’s own rule.
Derived from the same
_heartbeat_is_freshpredicate_maybe_reapconsults, so a UI and the reaper can never disagree about what “alive” means — a second definition living in a client is how a screen ends up insisting a job is fine while the server is failing it.Nonemeans unknowable, not dead: a job that is not running, or one that never beat.Falseis a positive claim that contact has been lost.
- worker_silent_s: float | None = None#
Seconds since this job’s worker last stamped a heartbeat.
Nonewhen it has never beaten — a record written before heartbeats existed, or a worker that died before its first beat. Absence is not a duration, and renderingNoneas0would report the silent case as the healthiest one.
- class nw.jobs.JobCost(estimated_usd=None, actual_usd=None, cache_hit_savings_usd=None, actual_is_lower_bound=None)[source]#
Bases:
object- actual_is_lower_bound: bool | None = None#
True when the run reported
has_unknown_costs— some call that actually billed had no price, soactual_usdUNDER-states the spend.Its whole job is to keep a
$0readable. Without it,actual_usdconflates two answers a spend surface must never merge: “this run cost nothing” (a cache hit — a known zero) and “we do not know what this run cost” (an unpriceable call ran). A UI that renders the second as free, or a bound that reads it as under budget, is the failure this field exists to make impossible.Nonemeans the render never reported either way — an older caller, or a job that died before finishing.Noneis notFalse: absence of the flag is not a claim that the total is exact.
- estimated_usd: float | None = None#
Predicted spend, re-quoted at today’s rates when a plan was supplied.
Nonemeans unknown, and unknown always requires approval — it is what a plan whose calls carry nofalaw.CostBasisre-prices to. Never the plan-time figure passed in alongside such a plan: that number was true when it was written and falaw’s rate tables have moved since (nw#74).
- class nw.jobs.JobProgress(stage_index=None, stage_count=None, current_transform=None, fraction=None)[source]#
Bases:
object
- class nw.jobs.JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s=<factory>, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs')[source]#
Bases:
objectTunables for the job manager. All keyword-configurable; sensible defaults.
ETA knobs (
n_min…prior_total_s) mirror the design report §5.7.- approval_threshold_usd: float = 1.0#
Estimated cost at/above which a render requires explicit approval.
- dur_buckets_s: tuple[float, ...] = (4.0, 8.0, 12.0)#
Upper edges of the output-duration buckets for
per_secondmodels.
- heartbeat_interval_s: float = 20.0#
How often a running worker stamps
heartbeat_aton its index record.Liveness has to be a fact in the shared store, not in one process’s memory, or a second API replica cannot tell a live job from a dead one. Cheap: one small atomic file write per job per interval.
- heartbeat_stale_s: float = 120.0#
A heartbeat younger than this proves the worker is alive anywhere.
Generously larger than
heartbeat_interval_s: the beat is a Python thread, and a render holding the GIL in a C extension can delay it well past one interval. The asymmetry is deliberate — a late beat costs a slower reap, while an eager one destroys a live job’s record.
- jobs_dirname: str = '.nw/jobs'#
Sub-path under
project.rootfor the job stores (nw’s.nw/convention).
- nw.jobs.UNREADABLE_PLAN_REASON = "params['plan'] could not be read as a falaw Plan, so its cost cannot be quoted; see the server log for what went wrong"#
Why a quote came back unknown when the plan itself was unreadable.
A fixed sentence rather than the exception text: this string reaches a job surface, and an exception’s message is written for an operator reading a log, not for whoever is deciding whether to approve a spend.
- nw.jobs.cancel_job(project, job_id, *, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
Request cancellation. Idempotent.
Noneif unknown.Sets a durable should-cancel flag (a running stage stops at its next boundary) and flips the au record terminal at once via
au.cancel_task(which builds the handle with the backend soterminateis reached). Thecancel_requestedflag is authoritative, so the job readscancelling→cancelledeven if the still-running thread later overwrites the store with COMPLETED.A job that has already finished is not cancelled — the call is a no-op and returns the job unchanged. Because the flag is authoritative for status, setting it on a terminal record rewrote a SUCCEEDED job to
cancelledand dropped itsresultandpct(and a FAILED job’serror): work that ran, finished and billed, reported as though it had not. Cancelling is a request about the future, and there is no future left to change.
- nw.jobs.enqueue(project, kind, params, *, on_event=None, dispatch=None, backend=None, idempotency_key=None, label=None, capture_context=None, secrets=None, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
Enqueue a billable render as a background job. Returns a
Job(status="queued") immediately — the call never blocks and never bills on the calling thread.The request context (fal credentials + the resolved
Project) is captured now and re-established inside the worker so the job outlives its request. If a live (non-terminal) job with the sameidempotency_keyalready exists, that job is returned instead of launching a duplicate.- Parameters:
project – the
nw.Projectthe render operates on.kind (
str) – dispatch key selecting the render callable (e.g."journey.full_auto","panel.animate").params (
dict) – render parameters (also the ETA-key + default-idempotency basis). A"plan"entry must be a ``falaw.plan_to_dict`` dict, not a livefalaw.Plan: the wholeparamsmapping is JSON-serialized into the job index, so aPlanobject raises there. The dict hashes to the identicalplan_hash(_plan_for_identity()) and re-quotes the same way, so nothing is lost by serializing it —estimate(), which never writes a record, accepts either. A"units"entry (a positiveint) says how manyoutput_kindunits the job’s wall-time covers —4for four image renders. Only a job that declares it teaches the shared coarseoutput_kindETA bucket, per unit (nw#67); seeDurationLearningMiddleware.on_event (
Callable[[Any],None] |None) – sink for the render’s lifecycle events (reelee wires this to itsagent_log/ SSE tail). Events are stamped withjob_id/run_idand mirrored into progress/cost/eta.dispatch (
Mapping[str,Callable] |None) –{kind: callable}table. Each callable is invoked ascallable(project, params, *, job_id, on_event, should_cancel)(only the kwargs it declares are passed) and should return a JSON-serializable result payload.backend (
ComputationBackend|None) – advanced override; default is the managedThreadBackend(which carries the duration-learning middleware + liveness map).idempotency_key (
str|None) – dedup handle; default derived fromfalaw.plan_hashofparams["plan"]when present, else a stable hash of params.label (
str|None) – human tray label; default derived fromkind/params.capture_context (
Callable[[],AbstractContextManager[Any]] |None) – optional caller-supplied context hook. Called now (on the request thread) to snapshot any request-scoped state the caller needs re-established inside the worker, returning a context manager entered around the render on the worker thread.nw.jobshandles fal credentials itself (falawis its dependency); this hook is how a caller re-binds credentials it owns withoutnwimporting them — e.g. reelee’s BYO vision (aix) + ElevenLabs keys, which otherwise fall back to owner/env in a background job becauseThreadBackenddoes not copyContextVarsinto the worker.secrets (
Mapping[str,str] |None) – the caller’s per-call credentials (nw.Secrets; any mapping is coerced), for a render that spends a bring-your-own key. Held in memory only: never written to the job index (paramsis — never put a key there), never logged, and it reaches the render callable only when that callable declares asecretskeyword — the same accepts-it-or-not rule asjob_id/on_event/should_cancel. A"fal"secret is also bound as the worker’s fal credential (nw.secrets.using_secrets()), innermost, so an explicit key wins over an ambient one. The explicit counterpart ofcapture_context: what that hook re-binds ambiently, this threads by hand.config (
JobsConfig) – tunables (seeJobsConfig).
- Raises:
KeyError – if
kindis not indispatch.ValueError – if
params["units"]is present but not a positiveint.
- Return type:
- nw.jobs.error_kind_of(error)[source]#
"refused"|"cancelled"|"crashed"for an exception a job raised.- Return type:
>>> from nw.genres import GenreOpRefused, GenreOpCancelled >>> [error_kind_of(e) for e in (GenreOpRefused("no song"), ... GenreOpCancelled(), ValueError("bug"))] ['refused', 'cancelled', 'crashed']
- nw.jobs.estimate(project, kind, params, *, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
Dry-run cost gate without enqueueing.
Returns
{estimated_usd, has_unknown_costs, approval_threshold_usd, requires_approval, quote}. Unknown cost always requires approval (preserves the one-price-per-clip gate).The gate quotes, it does not remember. When
params["plan"]is present its cost is re-quoted at today’s rates throughnw.pricing.current_quote(), and a caller-suppliedparams["estimated_usd"]is ignored — exactly as_default_idempotency_key()ignores it for the dedup basis. A persisted plan’s figure is frozen at plan time, and falaw’s rate tables move (0.0.46 re-quoted premium LLM calls tenfold upward), so gating on the stored number under-quotes the run: the one direction a spend decision must never err in (nw#74).A supplied plan that cannot be re-quoted — no
cost_basison its calls, an unparseable payload, a model that has left the catalogue — yieldsestimated_usd=None, which requires approval. Refusing to name a price is the safe answer; repeating yesterday’s is not.Without a plan the gate falls back to
params["estimated_usd"], whose provenance nw cannot see;quoteis thenNoneto say so. When there is a quote it carriescaller_estimated_usd— what the caller passed, reported beside today’s number rather than discarded, so a surface can show the movement.Raises
ValueErroron a malformedparams["units"], exactly asenqueue()does, so the gate never approves what enqueue refuses.- Return type:
- nw.jobs.get_job(project, job_id, *, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
One job (projecting the au status + mirrored index metadata).
Noneif unknown. Reaps a stale-RUNNING record on read.
- nw.jobs.list_jobs(project, *, status=None, limit=50, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
Jobs for this project, newest first, optionally filtered by status.
Backed by the per-project active-jobs index (not by scanning the au store, whose missing-key-returns-PENDING gotcha makes membership meaningless).
- nw.jobs.predict_total_s(eta_candidates, output_kind, *, durations, expected_cache_hit=False, units=None, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
Predict the total render seconds for a job as
(p50, p90, confidence).Walks the ETA-key candidates most-specific-first; the first key with
>= n_minsamples wins (median, with a real or synthesized p90). Falls back through the coarse key, the output-kind key, then the cold prior. An all-cache-hit plan short-circuits tocache_hit_floor_s("exact").unitsscales the per-unit answers — alearned_coarsehit and the cold prior — to the job; a specificlearnedkey already holds whole-job samples and is returned as is.Nonemeans one.- Return type:
_Prediction
- nw.jobs.summarize(project_root, *, limit=50, config=JobsConfig(n_min=3, sample_window_k=20, pct_ceil=99, overrun_factor=1.5, cache_hit_floor_s=0.5, dur_buckets_s=(4.0, 8.0, 12.0), prior_total_s={'image': 12.0, 'video': 90.0, 'audio': 15.0}, default_prior_total_s=30.0, stale_running_s=900.0, heartbeat_interval_s=20.0, heartbeat_stale_s=120.0, approval_threshold_usd=1.0, jobs_dirname='.nw/jobs'))[source]#
This project’s jobs without provisioning it. Newest first.
list_jobs()looks like a read and is not. It calls_runtime(), which creates.nw/jobs/{au,index,durations}and memoizes a_JobsRuntime— *which owns a renderThreadBackend``* — into the unbounded module-global ``_RUNTIMES. Measured on a never-rendered project:before: .nw/jobs exists? False _RUNTIMES size: 0 list_jobs(project) -> 0 rows after : .nw/jobs exists? True ['au', 'durations', 'index'] _RUNTIMES size: 1, runtime owns a ThreadBackend
A cross-project dashboard polling N projects is exactly the caller that turns that into render machinery for every project a user has ever glanced at, held for the life of the process. So this reads what is there and creates nothing: **no directory, no backend, no
_RUNTIMESentry**, and[]for a project that has never run a job.It also performs neither write
_read_job()does:No reaping. Marking someone else’s job failed is a write, and a read-only fan-out across projects has no business doing it. A job that needs reaping will be reaped by a caller that is actually looking at it.
**No
pct_floorpersistence.** The monotonic floor is a write too. Progress may therefore appear to tick backwards within this view if a prediction lengthens; that is the honest cost of not writing, and the per-project view still holds the floor.
Status still comes from the au store, never from the index record’s own
statusfield:FileSystemStoresynthesizesPENDINGfor a missing key, so membership lives in the index while outcome lives in the store, and trusting the index’s copy reports finished jobs as queued.