zettelkasten.build_jobs¶
zettelkasten.build_jobs ¶
Durable, in-process background worker for matrix/outline builds.
The dashboard caps its anyio worker threadpool to a SINGLE token (see
dashboard.backend.main.lifespan) so native kglite/BLAS work never runs
concurrently and SIGSEGVs. FastAPI runs sync def route handlers on that
pool, so a synchronous matrix/outline build endpoint holds the lone token for
the entire multi-second LLM build and freezes the whole dashboard. The SSE
streaming builds sidestep the token but die when the browser tab closes and
leave no resumable record.
This module moves those builds OFF the web threadpool into a dedicated
ThreadPoolExecutor and exposes them as fire-and-poll jobs the frontend can
reconnect to: POST .../build returns a job_id, then the client polls
GET /api/build-jobs/{job_id} until it is done/error, applying the
live partial progress in between.
Why a THREAD pool and not a process pool (unlike bundle_builder): the build
is driven by the in-process dashboard agent (the shared MCP bridge + its
executor), which cannot be pickled into a subprocess. So the LLM work must stay
in this process. That is safe because the pipeline's native GATHER already
serializes under zettelkasten.embeddings._NATIVE_LOCK (per-call
defense-in-depth) — exactly as the existing SSE streams do, whose GATHER runs
off the single token via asyncio.to_thread today. We deliberately do NOT
re-serialize the whole (mostly LLM) build under a global lock: that would just
re-block the dashboard, defeating the point. Only the brief native GATHER window
is serialized, by the same per-call lock the streams rely on.
Disable entirely with ZETTEL_BUILD_JOBS=0 (the build endpoints then 503 and
the frontend falls back to the SSE stream). Worker count defaults small and is
overridable with ZETTEL_BUILD_WORKERS.
jobs_enabled ¶
Public: whether the background build worker is available (jobs not disabled).
A caller that must fan work OUT as background jobs (rather than take
:func:run_under_review_gate inline on its own thread) checks this to pick the
routed path vs. the graceful inline fallback — e.g. the remine-apply route
schedules each member promote as its own kind="spine" job when enabled, but
runs them inline under the per-review gate when disabled.
Source code in zettelkasten/build_jobs.py
schedule_build ¶
schedule_build(kind: str, *, key: Any, make_agen: AgenFactory, sig: 'str | None' = None, attach_to: 'str | None' = None) -> 'str | None'
Schedule a background build, returning its job_id (or an in-flight one).
kind is "matrix" or "outline"; key is the stable dedup identity
(matrix: (review, table_id); outline: (review, outline_id)). make_agen
is a zero-arg callable returning a fresh async generator over the stream
pipeline's {"event", "data"} frames.
sig is an OPTIONAL params/force signature (a stable string the caller
derives from the request: force flag + columns / row_axis / claim_ids / …). It
disambiguates dedup from supersede for the SAME (kind, key):
- Dedup — a burst of requests carrying the SAME
sig(rapid double-submit of identical params) while a build is in flight returns the SAMEjob_idinstead of spawning a second build. - Supersede — a request whose
sigDIFFERS from the in-flight job's (e.g. the frontend re-Generates withforce=trueand edited columns) spawns a FRESH job, re-points the reconnect slot at it, AND cooperatively CANCELS the old job so it STOPS before its pipeline persists (see_drain) — a best-effort optimization to avoid wasted compute. The CORRECTNESS guarantee, though, is the serialization gate (_run_job/ :func:_gate_key): jobs sharing a gate run strictly one-at-a-time, so the new job runs its pipeline (and any writes) strictly AFTER the old one — the last-requested build always wins and two builds sharing a gate never write concurrently. For MATRIX that gate is per-REVIEW (not per-table_id), because all of a review's tables write the same shared spine nodes. Reconnect (find_active) is keyed by(kind, key)only — sig-blind — so a poller always reconnects to the LATEST build for the key.
sig=None (the default) preserves the historical key-only dedup: two
no-sig schedules for the same (kind, key) coalesce.
attach_to (an aggregate job_id) makes schedule-and-attach ATOMIC: when
set, the newly-created child job_id is appended to that aggregate's
child_job_ids INSIDE THE SAME _lock critical section that creates the
child record, BEFORE it is submitted to the pool. This closes the create-then-
attach race (#1): a fast child could otherwise finish AND be cap-pruned in the
window between the schedule returning and a separate attach_aggregate_child
call, so the aggregate would reference a gone child and under-count it. Attaching
under the create lock means the aggregate references the child before it can run,
with no timing premise. A no-op if attach_to is unknown / not an aggregate.
Returns None when background jobs are disabled/unavailable — the caller
(route) then reports that and the client falls back to the SSE stream.
Source code in zettelkasten/build_jobs.py
690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 | |
run_under_review_gate ¶
Run fn holding the per-review spine gate, then return its value.
This is the ONE in-process serialization point for shared spine-node writers.
Matrix builds acquire this same gate inside :func:_run_job, and routed
generate/materialize/resync/promote acquire it via :func:schedule_spine_write
(which schedules a kind="spine" job whose :func:_gate_key maps to the
SAME ("matrix", review) gate). Calling this helper directly is the
INLINE path — used only as the graceful fallback when background jobs are
disabled, so an op that must still run on the caller's thread nonetheless
serializes against every other in-process spine writer for the review.
The gate is per-REVIEW (see :func:_gate_key), so different reviews never
contend. fn runs on the CALLER's thread; its exceptions propagate after
the gate is released. Do NOT call this from inside a kind="spine"/matrix
job body — that body ALREADY holds the gate via :func:_run_job, so a nested
acquire would deadlock on the non-reentrant lock.
Source code in zettelkasten/build_jobs.py
schedule_spine_write ¶
schedule_spine_write(review: Any, dedup_id: Any, fn: Callable[[], Any], *, sig: 'str | None' = None, attach_to: 'str | None' = None) -> 'str | None'
Schedule a synchronous spine-node write (generate/materialize/resync/promote).
Routes fn — a zero-arg callable returning the JSON result dict — onto the
background worker as a kind="spine" job so it NEVER occupies the single
web-threadpool token. Its :func:_gate_key maps to the SAME ("matrix",
review) gate matrix builds take, so the routed write serializes against every
other spine-node writer for the review (the in-process half of the P0 fix; the
routed op's own review_write_lock remains the cross-process backstop).
The dedup/reconnect slot is ("spine", (review, dedup_id)) — DISTINCT per
op (use e.g. f"resync:{org_id}") so a routed write never dedups/supersedes
a matrix BUILD's slot (a different kind), while still sharing the gate.
sig disambiguates dedup-vs-supersede exactly as in :func:schedule_build.
attach_to (an aggregate job_id) is threaded through to
:func:schedule_build so the member child is attached to its aggregate ATOMICALLY
at create time (#1) — the remine-apply route passes the aggregate id here so each
member is referenced before it can run, closing the create-then-attach race.
Returns the job_id (poll GET /api/build-jobs/{job_id}; the result dict
lands in the record's result), or None when background jobs are
disabled/unavailable — the caller then runs a graceful inline fallback.
Source code in zettelkasten/build_jobs.py
schedule_aggregate ¶
schedule_aggregate(child_ids: 'list[str]', *, kind: str = 'remine', key: Any = None, members_total: 'int | None' = None, baseline_edges: int = 0, result_extra: 'dict | None' = None, inline_error: 'str | None' = None) -> str
Register a DERIVED aggregate handle over member sub-jobs.
Returns a fresh aggregate job_id the client polls exactly like any other
build job (GET /api/build-jobs/{job_id}). Unlike a real job the aggregate
is NEVER executed by a worker — it is not submitted to the executor and takes
no serialization gate / _key_pending / _active slot. Its
status/result/error/progress are DERIVED ON READ from its
children by :func:_derive_aggregate_locked (see there for the rules), which
ACCUMULATES each child's contribution as it terminates so the handle is
self-healing against child pruning.
child_ids are the member sub-job ids known at creation (e.g. the per-member
kind="spine" remine-apply promotes). To close the PRE-CREATION eviction gap
(P2-a), a caller should register the aggregate FIRST with child_ids=[] and an
explicit members_total=N, then :func:attach_aggregate_child each child as it
is scheduled — so a child can never be cap-evicted before the aggregate that
counts it exists. members_total is the TRUE total member count (scheduled +
inline + baseline); when None it defaults to len(child_ids) (the historical
all-children-known-up-front shape). Until counted reaches members_total the
aggregate never reads terminal, so an up-front handle with no children yet reads
pending rather than a premature done.
baseline_edges folds in edges already written OUTSIDE the children (e.g. a
member that had to run inline because the pool vanished mid-fan-out) so the
aggregate's edges_written still totals correctly. result_extra carries the
non-derivable aggregate result keys the route's contract expects (e.g. applied
/ schema_id for remine-apply) — they are merged into the derived result
alongside edges_written.
inline_error seeds an error that surfaces via the poll ONCE all members are
terminal (P1b). It represents a member that FAILED while being run inline
mid-fan-out (the pool vanished) — rather than orphaning the already-scheduled
children behind a bare synchronous 404, the failure rides on the aggregate as an
errored contribution, exactly like a child job error.
Source code in zettelkasten/build_jobs.py
attach_aggregate_child ¶
Attach a freshly-scheduled child sub-job to an existing aggregate (P2-a).
Called right AFTER the child is scheduled so the aggregate — registered UP FRONT
with a known members_total — references the child before it can finish and be
cap-evicted (promotes are slow, so a child cannot terminate in the microseconds
between schedule and attach; this closes the pre-creation fold gap). No-op if the
id is unknown or not an aggregate, or if the child is already referenced. The
derived view is refreshed so a poll immediately reflects the new member.
Source code in zettelkasten/build_jobs.py
fold_aggregate_inline ¶
Fold an INLINE-run member (baseline edges, or a failure) into an aggregate.
An inline member never became a child job (the pool vanished mid-fan-out), so it
is folded DIRECTLY here rather than via :func:_derive_aggregate_locked: its
edges add to baseline_edges (a success) and/or its error seeds the
deferred aggregate error (a failure, surfaced once everything is terminal — P1b).
Either way counted_members is bumped by one so counted still reaches the
TRUE members_total that includes inline members. No-op for an unknown id.
Source code in zettelkasten/build_jobs.py
terminalize_aggregate ¶
Drive a still-non-terminal aggregate toward a terminal ERROR (#3b).
Called from the remine-apply fan-out finally when an UNEXPECTED exception
escaped mid-loop AFTER >=1 member was already scheduled but BEFORE every member
could be attached/folded. The aggregate was registered up front with the TRUE
members_total (all members), so the phantom members that will now never
arrive keep counted < members_total forever — the handle could never derive
terminal and would hang until the idle reap (a silent, slow 404 for the client).
This reconciles members_total DOWN to only the members that will actually be
counted (already-counted inline/folded members + still-referenced in-flight
children) and seeds a deferred error. The already-scheduled children are NOT
abandoned: they still fold in normally, and once the last of them terminates the
handle derives to error PROMPTLY (P1b — the error stays deferred until every
referenced member is terminal, so a still-writing sibling is never cut off). No-op
for an unknown id, a non-aggregate, or an already-terminal aggregate.
Source code in zettelkasten/build_jobs.py
discard_aggregate ¶
Drop an aggregate handle created up front but no longer needed.
Used when the route registered the aggregate FIRST (P2-a) but then took a path that returns no pollable handle — every member ran inline (the direct-result dict), or a pure-inline failure surfaces as a synchronous 404 — so the orphan handle would otherwise linger until its TTL. No-op for an unknown id / non-aggregate.
Source code in zettelkasten/build_jobs.py
get_job ¶
Return a deep COPY of the durable job record (or None if unknown).
Copied under the lock so the caller reads a consistent snapshot even while a
worker thread is mutating the live record. An AGGREGATE record's derived
state is recomputed from its children first (see :func:schedule_aggregate /
:func:_derive_aggregate_locked) so the returned snapshot is current.
Source code in zettelkasten/build_jobs.py
find_active ¶
Return the job_id of an IN-FLIGHT build for (kind, key) (reconnect).
Source code in zettelkasten/build_jobs.py
shutdown ¶
Tear down the build pool (best-effort). Safe to call when never started.