Repository navigation
Make the scheduler trim-safe and persist plain job records - #12
Merged
Merged
Conversation
Stores now hold JobRecord and JobExecutionRecord values instead of runtime Job and JobExecution objects, so JSON-encoded stores round-trip (reading a persisted Job back failed while rebuilding its ReentrantLock). Job actions live in the Store object they were added to; restoreJobs! is the explicit, reflection-based way to resolve stored named functions. Actions, retry checks, and timezone schedule lookups are type-erased behind a @cfunction callback, and the scheduler path uses concrete types: Scheduler{S<:Store}, a non-parametric Cron with explicit field dispatch, typed store reads, and no repr(::Symbol), invokelatest, or errormonitor. The trim workload now covers an application's use (JSON FileStore, closures over app state, run!, retries, restart) and executes jobs in the binary. Breaking: version 4.0.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The trim workload closed the scheduler through an abstractly typed field, but inference could see the concrete scheduler there, so it did not test that case. Behind a function barrier, close(::Scheduler) is a dynamic call that juliac --trim=safe rejects. close is now unspecialized, uses the getfield and setfield! builtins, and polls without a closure over the scheduler, so one compiled method serves every store type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
quinnj-bot
Bot
force-pushed
the
claude/workshop-trim-scheduler
branch
from
October 30, 2026 17:07
a3162e9 to
e60bc8b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
Tempus' scheduler could not be compiled with
juliac --trim=safe, and a JSON-encoded store did not work even in normal Julia:Jobobject (itsReentrantLockand actionFunction) and wholeJobExecutions. WithFileStore(dir; codec=JSONCodec()), reading a job back fails withMethodError: no method matching Base.IntrusiveLinkedList{...}(::Nothing, ::Nothing)while StructUtils rebuilds the lock.Scheduler.store::Storewas an abstract field, so every store call from the dispatch loop was dynamically dispatched. Job actions were stored asaction::Functionand called throughinvokelatest.parseCron(::String)produced aCron{S,M,H,D,Mo,DoW}whose type depends on the string, soJob.schedulewas abstract._function_ref(f::Function)was not specialized and sliced a variable-lengthfullnametuple.JobOptionscalledrepr(::Symbol)(about 200 verifier errors in Base's expression printer) and heldretry_checkasAny. Timezone support (validating the name, computing a zoned schedule) reaches TimeZones' database loading, which is not trim-safe (it reads tz files and can download tzdata), and becausetimezoneis a runtime value that code was compiled into every scheduler program.errormonitorpulls in Base's error display.Serialization, which re-resolves functions by name; that cannot exist in a trimmed binary.What changed
JobRecord(definition and disabled state; schedule as its cron string,overlap_policyas a string) andJobExecutionRecord(id, times,succeeded::Bool). These round-trip throughJSONCodec,SerializedCodec, and memory. JSON.jl cannot decodeSymbolfields without dynamic dispatch, so records use strings and aBoolinstead.addJob!(store, job)(andpush!(scheduler, job)) persists the record and keeps the job's action in thatStoreobject. A scheduler runs a stored job only when a job of that name was added in this process; any function works, including closures over application state.restoreJobs!(store)is the explicit opt-in that resolves stored named functions throughresolve_function; it uses reflection and is documented as unavailable in trimmed binaries.Callback{C}: a@cfunctionpointer specialized on the callable's type, called with a fixed-signatureccallthat passes a concrete call record (the approach of Servo'shandlerfn.jl, without a dependency). Calls through the pointer run in the latest world, soinvokelatestis gone. The type is mutable so a callback built during precompilation (whose pointer Julia saves asC_NULL) throws anArgumentErrorinstead of calling a stale pointer.Scheduler{S<:Store};Cronis one concrete type whose fields are a five-type union, dispatched through an explicitcronsplitbecause inference splits at most four types;Cron ==compares expressions (so separately parsed list schedules are equal). Store reads are asserted to the view's element type, so an untypedMemoryStore(the defaultInMemoryStore()) stays static too._function_refspecializes on the function and walksparentmodule.JobOptionsvalidates withoutrepr(::Symbol), forces specialization onretry_check, and builds the timezone callback only when atimezoneis given, so TimeZones' database code is only compiled into programs that use timezones. Execution ids format seconds without milliseconds (Julia 1.13's trim verifier rejects the millisecond padding). Background-task failures are reported with@errorand rethrown instead oferrormonitor.parseCronrejects"0 0 30 2 *") now goes throughcronsplitlike the rest of the cron code, because a cron field is now a five-type union.Closing a scheduler held in an abstractly typed field
The trim workload closed the scheduler through an abstractly typed field, but inference could see the concrete scheduler there, so the check did not cover that case; behind a function barrier,
close(::Scheduler)was a dynamic call.closeis now unspecialized, reads and writes fields with thegetfield/setfield!builtins, and polls for the stop without a closure over the scheduler, so one compiled method serves every store type. The workload now closes the scheduler behind a@noinlinebarrier; it fails verification without this change.Breaking changes (version 4.0.0)
Tempus.restoreJobs!(store).getNMostRecentJobExecutionsreturnsJobExecutionRecords (succeededinstead ofstatus; noresult/exception, which stay on the in-processJobExecution).Store(jobs, executions)takesAbstractStore{JobRecord}andAbstractStore{Vector{JobExecutionRecord}}.Job.actionis a callback wrapper (still called asjob.action()), ornothingfor a stored job without an action in this process.SchedulerandCronchanged type parameters;JobOptionsgained an internalzonefield.How this was verified
test/tempus_trim_state.jlnow mirrors an application: aJSONCodecFileStore,Tempus.Store(backend; prefix="tempus/"), jobs whose actions are closures over app state added at every startup,getJobs/schedule comparison, aSchedulerwith aretry_check,run!, a one-shot job that fails once and is retried, a restart that reads the persisted records back,closethrough an abstractly typed field, history,disableJob!, andpurgeJob!.juliac --trim=safe(JuliaC 0.3.12) on Julia 1.14.0-alpha2: 0 verifier errors, and the binary runs the jobs. On Julia 1.13.3 the same workload with aMemoryStorebackend: 0 errors, and the binary runs. On 1.13 aJSONCodecstore cannot verify for a reason outside Tempus: typedJSON.parseof any nested struct (hereAbstractStores.Entry{JobRecord}) leaves an "unresolved invoke" of StructUtils' innermake. A standalone program that only callsJSON.parse(bytes, Outer{Inner})on two plain structs reproduces it on 1.13.3, and the same program verifies on 1.14. The workload therefore uses memory on Julia < 1.14.JSONCodecFileStoreacross two starts with persisted history; a JSON round trip of a job's options (includingtimezone) andrestoreJobs!; precompilation-pointer failure; list-schedule equality. Existing tests were updated for records andrestoreJobs!.🤖 Generated with Claude Code