packages/java is to be a Maven build whose main artifact is dev.cronwatch:cronwatch on Maven Central: the same library as @cronwatch/sdk, for services on the JVM, from a main a crontab runs to a Spring Boot app with @Scheduled methods or Quartz inside it. It is a port, not a new design, made the way the Ruby gem and the Python, PHP, Go, Rust, and Elixir ports were (see their DESIGN.md), and like the last five it is made in phases. The Rust port is the nearest model for how the work is laid out and audited, the Go and Rust ports for a typed, threaded language, and much of what those ports wrote for themselves (the croner port, the JavaScript regular expression engine, the JSON writer, the store dialects, the origin reader, the POST hardening, the bridge) is carried over nearly line for line; the Elixir port is the model for a draft written against every audit that came before it. The TypeScript SDK is the source of truth for every behaviour, message, and stored byte; when the two disagree, the Java side is wrong.
The owner settled the draft's open questions before phase 1, taking every recommendation (see Decisions, which also records what phases 1 to 4 settled that the draft left open). Phases 1 to 5 are done (see Phases), phase 5 being the audit before the first release.
- Java 21 or newer, tested on 21, on 25 (the current long-term release), and on the newest release (27, out 2026-09-15) in CI. The floor is set by what the library uses and by what the frameworks it serves ask for, not by syntax alone (see Decisions, 3). Spring Boot 4 (4.0 and 4.1) and Spring Boot 3.5 all run on Java 17 or newer, and Quartz 2.5 on 11, so none of them forces 21; 21 is chosen because it gives the port what it would otherwise have to build: virtual threads (final in 21) for the client's own work,
SequencedMapfor the ordered JSON object, pattern matching forswitchand record patterns for the JSON reader and the wire values, anHttpClientthat isAutoCloseable(21), andDouble.toStringgiving the shortest digits that read back to the same double (19, JDK-4511638), which the JavaScript number writer is laid out from. Java 17 is still widely run, and the cost of 21 is that a Spring Boot app still on 17 cannot use the library until it moves; Java 17's free updates from Adoptium run to 2027, 21's to 2029. - One Maven build, several artifacts (see Coordinates and releases). The core,
dev.cronwatch:cronwatch, has no dependencies at all: itspom.xmlnames none incompileorruntimescope, and the Maven Enforcer plugin fails the build if one appears, the counterpart of the Go core's emptygo.mod. Cron parsing and fire times are a port of croner 10 (dev.cronwatch.internal.cron, carried over from the Rust port'sschedule::cron, itself the Go port's), zones come fromjava.time's own copy of the IANA database, JSON is written and read bydev.cronwatch.internal.js(not Jackson or Gson, whose numbers and key order differ; see below), HTTP isjava.net.http.HttpClient(see Channels), SHA-256, HMAC, MD5, and strong randomness come from the JDK (MessageDigest,Mac,SecureRandom), constant-time comparison isMessageDigest.isEqual, and secret redaction runs ondev.cronwatch.internal.jsre, the Go and Rust ports' small backtracking engine with JavaScript's semantics, carried over (see Redaction). JDBC (java.sql) and the JDK's HTTP server (jdk.httpserver) are modules of the JDK, not dependencies, and the core requires them only asrequires staticin itsmodule-info.java, so ajlinkimage of an app that uses neither can leave them out. - Every artifact is a named JPMS module (
dev.cronwatch,dev.cronwatch.servlet,dev.cronwatch.spring,dev.cronwatch.quartz), exporting only its API packages;dev.cronwatch.internal.*is not exported, so the croner port, the engine, and the JSON reader can change in any release. On the class path the same jars work as they always have. - Times are
longepoch milliseconds everywhere, as in the SDK and the other ports, soevaluateports line for line and stored rows are identical. Durations aredoublemilliseconds inside (a definition may hold1.5, which the SDK keeps), and the public API takesjava.time.Duration, the SDK's text, or plain milliseconds where Java code would pass one. A run's written duration isrunDuration(startedAt, finishedAt), the SDK's: the difference taken without overflow (Math.subtractExactcaught and saturated), 0 when the start is later, and at most 2^53 - 1, so a foreign row whose start isLong.MIN_VALUEmakes a duration every store and port holds exactly. A state's version counts asstateVersionreads it: a JSON number that is a whole number from 0 to 2^53 - 1 (so2.0is 2), else 0, so a foreign row's1.5or"x"is written over by the next update; the store SQL reads it the same way. - Anything that leaves the process (store rows, JSON columns, alert JSON) uses the SDK's exact field names, key order, and values, so a Node, Ruby, Python, PHP, Go, Rust, Elixir, and Java process can share one database and
@cronwatch/mcpworks against any of them.Js.stringifyisJSON.stringifybyte for byte: numbers as JavaScript prints them (2not2.0,1e-7,1e+21,123456789012345680000; the digits come fromDouble.toStringand are laid out asNumber.prototype.toStringlays them out, since Java writes1.0E21), alongbeyond 2^53 written as the double JavaScript would hold, a value that is not finite writtennull, keys in JavaScript's order (array-index keys first in ascending order, then the rest as inserted;JsObjectkeeps that order over aLinkedHashMap), only control characters, quotes, and backslashes escaped (never<,>,&, or U+2028). Jackson and Gson were weighed and left out of everything that leaves the process: both write2.0for the double 2 and1.0E21for 1e21, Jackson's maps keep insertion order but never put array-index keys first, and either would be the core's first dependency. Each public record hastoJson()for an app returning aJobSummaryfrom its own controller; the stores and channels never use Jackson, and the Spring starter registers nothing with the app'sObjectMapper. - Java strings are UTF-16, as JavaScript's are, so
String.length(),substring, andcharAtcount and cut exactly as the SDK does and a lone surrogate can be held, with none of the other ports' UTF-16 helpers. What differs is the way out:String.getBytes(UTF_8)writes a lone surrogate as?, where a JavaScript string written as UTF-8 (to a store, a hash, a request body) has U+FFFD. Every byte that leaves the process is encoded through oneCharsetEncoderwhose replacement is U+FFFD (Js.utf8), and the two places the SDK sends a lone half on the wire as JSON (Slack's and Discord's cut bodies, triage's prompt) write it\ud83d, asJSON.stringifydoes.String.trim()and\sinjava.util.regexare not JavaScript's whitespace, so trims go throughJs.trim. - A definition keeps its fields in the order they were given. The SDK writes
{ ...defaults, ...options, name }withexpectmoved last, so the stored JSON follows the object literal an app wrote; Java has no literal to follow, so job options are a builder whose setters append in the order called (a setter called twice keeps its first place and its last value, as a JavaScript object does), as the Rust port's are. Where the SDK takes an object of the app's own keys (budget,floor, metrics), aMapis written in its iteration order, so aLinkedHashMaporMap.ofpairs given through the builder keep the order the app meant, and aHashMapgives its own. Fields a newer writer added are kept. - Alert titles and messages are the SDK's text, character for character, with numbers as
toLocaleString("en-US")writes them (the Go port'sformat.go, through Rust'sformat.rs): ICU's rounding, from the shortest decimal digits of the double, half away from zero.java.text.NumberFormatrounds half even from the binary value and is not used. - Every condition opens once and closes with a recovery. No repeat alerts. A job whose schedule is removed while missed is open has missed closed by the next check with a recovery of its own (
reason: "unscheduled"). - A job's function may throw anything, checked or not, and each throw is a failed run; the throwable is then thrown again as it came, its type and stack intact, so no run is left
runningto be reported stuck later (see Runs). AnError(OutOfMemoryError,StackOverflowError) is recorded where recording is still possible and always thrown again. The store failing never stops a job: store errors go to the error handler, and the job's own outcome is returned. - A throwable is written
Name: messageand up to five frames, innermost first, eachat com.example.Reports.build (Reports.java:42), as a JavaScript stack reads (the class and method, then the file and line in parentheses, whereStackTraceElement.toStringjoins them). The name is the class's simple name (IOException,SQLException,PSQLException), as JavaScript'serror.nameis short, and the binary name for an anonymous or local class, whose simple name is empty. The message isgetMessage(), or none when it is null, asnew Error()has an empty one. Causes are not written, since the SDK writes only the error's own stack. - The environment is read in one place,
Env: the first ofCRONWATCH_ENVandAPP_ENVwhose value, trimmed as JavaScript trims, is not empty (a value of only spaces counts as unset), lowercased, as every port and the SDK read them (the SDK then readsNODE_ENV);development,dev, andlocalare development andprodis production, as in every port;testandtestingare not development (since 0.12.6: a test run or CI needsCRON_SECRETor a token, as production does). Java has no variable of its own the way Go, Rust, and Mix have, so the third place is the Spring starter's, which reads the app's active profiles when neither variable is set (see Decisions, 10). The other reads are the SDK's own:CRON_SECRET,CRONWATCH_TOKEN, and triage'sANTHROPIC_API_KEYandANTHROPIC_BASE_URL. All are read when used;CRON_SECRETandCRONWATCH_TOKEN, and a token or secret given in code, are trimmed as JavaScript trims, and a blank one is unset. - Thread safe throughout: the client, a job, a run handle, a job context, and every store may be used from any number of threads, platform or virtual. The library's own code holds
ReentrantLocks, neversynchronizedaround blocking work, so a virtual thread running a job is never pinned by it on Java 21 to 23 (JEP 491 removed the pinning in 24). - Nothing prints a secret. A record's generated
toStringprints every component, so every record or class that holds a token, a secret, a webhook URL, or an API key (each channel's options, triage's, the dashboard's and the handler's, the client's configuration, a request's headers, cookie, and query) overridestoStringto name what is set, never its value, and a test holds every public type to it. This is the Rust audit'sDebugrule and the Elixir audit'sInspectrule, and in Java it must be written from the start, since the default is the leak. - No em or en dashes anywhere, as in the rest of the repo.
The group is dev.cronwatch, verified on the Central Publisher Portal by a DNS TXT record on cronwatch.dev: the Portal gives a verification key, the owner adds it as a TXT record at the domain's DNS, and the Portal checks the exact domain. A namespace under a domain is granted only to whoever proves the domain, so no one else can hold it, and it covers every group beneath it (dev.cronwatch.*). The record is the owner's to add, at the registrar, and changes nothing the site serves. The fallback, if the owner would rather not, is io.github.cronwatchdev, which the Portal verifies through the GitHub account; it names a personal account in every app's build file forever, as the Go design said of a GitHub module path, so it is only the fallback.
The artifacts, all released together at the release's version:
dev.cronwatch:cronwatch, the core: the client, runs and handles, check, the memory store and the SQL store over JDBC, the pg_cron source, the alert channels, triage, the dashboard and a job's handler, framework-free, with an adapter for the JDK's own HTTP server, the bridge the integrations share, and the command-line entry. Moduledev.cronwatch; packagesdev.cronwatch(the API),dev.cronwatch.store,dev.cronwatch.jdbc,dev.cronwatch.alerts,dev.cronwatch.triage,dev.cronwatch.pgcron,dev.cronwatch.web,dev.cronwatch.bridge,dev.cronwatch.cli,dev.cronwatch.storetest(the store contract test for a store of the app's own, used only from its tests), anddev.cronwatch.internal.*, not exported.dev.cronwatch:cronwatch-servlet: the dashboard and a job's handler as a Jakarta ServletFilterandServlet, for Servlet 6.0 or newer (Tomcat 10.1, Jetty 12, Undertow 2.3, and any Jakarta EE 10 or 11 server).jakarta.servlet-apiisprovided, as the container brings it.dev.cronwatch:cronwatch-spring-boot-starter: auto-configuration for Spring Boot 3.5 and 4 (see Decisions, 5): the client as a bean fromcronwatch.*properties, the dashboard on Spring MVC through the servlet filter or on WebFlux through aWebFilter, a job's handler, the@Scheduledintegration, and Quartz's listener when Quartz is present. Its Spring dependencies areprovided-like (optional), so the app's Boot version is the one that runs. The starter and its auto-configuration are one artifact, which Spring's documentation allows for a small starter; a separate-autoconfigureartifact adds a jar and nothing an app can see.dev.cronwatch:cronwatch-quartz: the Quartz 2.5 integration, aJobListener, for apps with or without Spring.dev.cronwatch:cronwatch-jobrunr: the JobRunr 8 integration, a server filter, which phase 4 kept (see Decisions, 16).
A JDBC store in an artifact of its own (cronwatch-jdbc) was weighed and folded into the core (see Decisions, 4): it needs nothing beyond java.sql, which is part of the JDK, the drivers are the app's, and the pg_cron source and the store contract test sit beside it; splitting it would add an artifact every database app needs and leave the core's store package half a feature. The servlet adapter is separate for the opposite reason: it compiles against jakarta.servlet, which the JDK does not have.
The build is Maven, with the wrapper committed (mvnw, .mvn/wrapper), so CI and a contributor need only a JDK, as cargo and mix need only their toolchains. Maven over Gradle because the build is small and declarative (six modules, no code generation, no plugins of the repository's own), the Central Portal's official publishing plugin is Maven's (org.sonatype.central:central-publishing-maven-plugin; Gradle has only community plugins or JReleaser for the Portal), a pom.xml is what release.mjs can bump with one pattern, and it is the build most Java libraries publish with; Gradle's strengths (incremental builds of large graphs, build logic in code) do not arise here (see Decisions, 2). The version lives in one place, <revision> in packages/java/pom.xml, the parent and aggregator; each module's version is ${revision}, and the flatten-maven-plugin writes the resolved version into the POMs that are published, as Maven's CI-friendly versions require. Cronwatch.VERSION is read from a resource Maven filters with the version when it builds (dev/cronwatch/version.properties), so it needs no row of its own, and triage's user agent is cronwatch-java/<version>.
scripts/release.mjs gets its two rows in phase 1, with the build (the script refuses to run while a directory under packages/ lacks them, so until then this file alone holds up a release, as the Rust and Elixir designs did before their phase 1): in VERSIONED, { file: "packages/java/pom.xml", pattern: /^(\s*<revision>)([^<]+)(<\/revision>)/m }; in PUBLISH, the line that says the pushed tag publishes the artifacts through java.yml, and the command to do it by hand. Maven Central reads artifacts from the bundle uploaded to it, not from a repository, so a build in a subdirectory of a monorepo needs no split repository (the PHP package's case) and no tag prefix (the Go module's); the release's own tag vX.Y.Z is the only tag. Each module's <scm> names the repository and the tag.
Maven Central asks more of a release than crates.io or Hex: every artifact carries its sources jar, its javadoc jar, a POM with a name, description, URL, license, developers, and SCM, checksums, and a PGP signature on each file (Sigstore bundles are accepted beside them since 2025, but PGP is still required). A release cannot be deleted or replaced once published, only superseded, which is one more reason nothing is published before phase 1 works. See Releasing.
packages/java/
pom.xml mvnw mvnw.cmd .mvn/ README.md LICENSE DESIGN.md
cronwatch/ the core
src/main/java/module-info.java
src/main/java/dev/cronwatch/
Cronwatch.java the client: builder, job(), run(), check(), start()/stop(), silence(), forget(), close()
CronwatchBuilder.java the client's options, checked at build()
JobOptions.java the ordered builder; durations as text, Duration, or milliseconds
Job.java JobContext.java a declared job's handle; the run a function is given, current()
JobCallable.java JobRunnable.java the function types, generic in what they throw
RunHandle.java StartOptions.java start(), resume(), resumeRun()
Run.java StoredJob.java JobState.java Alert.java JobSummary.java CheckResult.java Metrics.java records
RunStatus.java Condition.java AlertType.java JobHealth.java the wire values (see The Java API)
Channel.java Triage.java Source.java Console.java the interfaces and the default channel
CronwatchException.java
src/main/java/dev/cronwatch/store/ Store and its optional methods, MemoryStore, and SqlStore: SQLite, Postgres, MySQL, and MariaDB over a DataSource
src/main/java/dev/cronwatch/jdbc/ SqlStore's deprecated alias, its package before 0.11
src/main/java/dev/cronwatch/storetest/ the store contract test (StoreContract.run, the one promise)
src/main/java/dev/cronwatch/alerts/ the channels, a class and its options each, with Email, SigV4, Transport, and JdkTransport
src/main/java/dev/cronwatch/triage/ Claude triage over plain HTTP (triage/anthropic.ts)
src/main/java/dev/cronwatch/pgcron/ the pg_cron source (sources/pgcron.ts)
src/main/java/dev/cronwatch/web/ Routes, Handler, Request, Response, Adapters, WebServer (the JDK HttpServer adapter)
src/main/java/dev/cronwatch/bridge/ what the integrations share (the Go, Rust, and Elixir ports' bridge): SchedulerBridge, Watch; outside the 1.x promise
src/main/java/dev/cronwatch/cli/ CronwatchCli: check from a crontab line
src/main/java/dev/cronwatch/internal/
core/ what runs, handles, and checks share: update_state, record_finish, conclude, the job locks
evaluate/ the alert rules, pure functions (evaluate.ts), format (format.ts), serialize, stats
duration/ durations (duration.ts), the 64 character cap included; schedules (schedule.ts)
cron/ croner 10: pattern, date (the walk), zones matched without regard to case
js/ numbers, stringify and parse (JsObject keeps order), UTF-8 out with U+FFFD, \s and trim, Date.UTC and toISOString
jsre/ the JavaScript regular expression engine: parse, match
output/ the output cap, error messages, redaction, the run recorder (output.ts, job.ts)
post/ Post, the one hardened POST, and the WHATWG URL reader (shared.ts)
web/ routes, html, timeline, pwa, request reading, origin, escaping
sql/ the dialects: SQLite and Postgres text from sql.ts, MySQL from the Go port
src/main/resources/dev/cronwatch/web/assets/ style.css, app.js, sw.js, icons: the SDK's files, byte for byte
src/test/java/... conformance replays, the SDK's tests ported, stores, web
cronwatch-servlet/ the Filter and Servlet adapters (CronwatchFilter, CronwatchServlet)
cronwatch-spring-boot-starter/ auto-configuration, properties, the @Scheduled and ShedLock integrations
cronwatch-quartz/ the Quartz JobListener and check job
cronwatch-jobrunr/ the JobRunr server filter and check job
webtest/ not published: the dashboard's golden replay and a raw HTTP client, the adapters' tests' shared part
webserver/ not published: the seeded dashboard @cronwatch/mcp's end-to-end test drives
examples/crontab/ not published: a job and its check run from two crontab lines, on SQLite
webtest, webserver, and examples/crontab are modules of the build that are never deployed (maven.deploy.skip, and left out of the Portal bundle); the tests, the repository's conformance/, and the fixtures never reach a published jar, and CI checks each jar's file list (see Testing and CI). The dashboard's assets are resources in the core's jar, read once through Class.getResourceAsStream when the routes are first built.
var cw = Cronwatch.builder()
.store(SqlStore.postgres(dataSource)) // default: a MemoryStore
.alert(Slack.webhook(System.getenv("SLACK_WEBHOOK_URL"))) // default: Console
.retention("30d")
.build(); // AutoCloseable: stops the interval and the client's threads
Job nightly = cw.job("nightly-report", JobOptions.builder()
.schedule("0 2 * * *").timezone("UTC")
.grace("15m").timeout(Duration.ofMinutes(30))
.expect("Report written").budget("cost", 2)
.failuresBeforeAlert(1));
Path path = nightly.call(job -> {
Path p = reports.build(job); // job.cancelled() turns true at the job's timeout
job.log("Report written: " + p);
job.metric("cost", 1.2);
return p; // an IOException thrown here is thrown from call()
});
CheckResult result = cw.check(); // missed and stuck runs, retries, pruning
cw.startChecking(Duration.ofMinutes(1)); // a check every minute, for a long-running service
cw.silence("nightly-report", "2h");The shape follows Java's conventions while keeping the SDK's names recognisable: cronwatch({...}) is Cronwatch.builder()...build(), cw.job(name, options) is cw.job(name, options), job.run(fn) is job.run(fn) (or job.call(fn) for a function with a value), and recordRun, resumeRun, jobsWithRuns, and jobSummary are recordRun, resumeRun, jobsWithRuns, and jobSummary, since the SDK's names are already Java's case. The calls block: a thread is cheap on the JVM from 21, so there is one API, where Python needed cronwatch.aio and Rust a blocking client beside its async one; an app on Reactor or coroutines calls it on a thread meant for blocking, as it calls JDBC.
- A builder for the client, an ordered builder for jobs. For jobs it is how a definition keeps its fields in the order given (see Rules). For the client it keeps the SDK's three-way options readable:
cronSecret(s)andnoCronSecret()where the SDK has a string,null, and absent;redact(fn)andnoRedaction()where it has a function andfalse;deliver(Deliver.AT_CHECK).defaults(JobOptions)takesgrace,timeout,timezone, andfailuresBeforeAlertandbuild()refuses others. A bad option, schedule, or zone is aCronwatchExceptionfrombuild()orjob()with the SDK's message, never from the first run. - Durations take the SDK's text (
"15m","1h30m"), ajava.time.Duration, or milliseconds (longordouble), through overloads; text is stored as written and everything else as its milliseconds, as the Go, Python, Rust, and Elixir ports store theirs. Text longer than 64 characters is refused before it is read, quoting its first 32, the SDK'sduration.tsrule, whichduration.jsonholds every port to. ADurationtoo large for a double's exact milliseconds is refused, asInfinitywould be. - Expect is three setters:
expect(text)(contains),expectMatch(source, flags)for a JavaScript pattern (stored asmatches /source/flags, run by the engine), andexpectThat(Predicate<String>)(stored ascustom function; a throw in it fails the run withOutput check threw: ...). Ajava.util.regex.Patternis not taken: it reads a pattern differently from JavaScript (see Redaction), and a stored pattern must read the same in every port. - Job functions are one of two functional interfaces, each taking the run's
JobContext:JobCallable<T, E extends Exception>(T call(JobContext job) throws E), given tojob.call(fn), which returns what it returns, andJobRunnable<E extends Exception>(void run(JobContext job) throws E), given tojob.run(fn). They are two names rather than one overloaded asExecutorService.submitis, sincesubmit's lambdas take no parameter and these take one: javac reads a one-parameter lambda as fitting both and refuses the call as ambiguous, whatever its body (see Decisions, 22).rundeclaresthrows E: Java infersEfrom what the lambda's body throws, so a function that throwsIOExceptionmakesrunthrowIOException, and one that throws nothing checked makes it throw nothing checked (Eis inferred asRuntimeException), with no wrapping and nothrows Exceptionforced on every caller (see Decisions, 7).JobContexthaslog(String),metric(String, double),cancelled()(the SDK'ssignal.aborted, true from the job's timeout on), andonCancel(Runnable)(the signal'sabortevent); the function decides what to do, as a JavaScript function decides what to do with an aborted signal. AStringthe function returns is the output when nothing was logged (and whatexpectchecks). - Channels, triage, and sources are interfaces:
Channel(name(),send(Alert, ChannelContext)),Triage, andSource(name(),sync(Cronwatch)), each with a static factory for a lambda (Channel.of("pager", (alert, ctx) -> page(alert))). Each throwsException; the client treats a throw as that channel's failure only. - Stores are the
Storeinterface. The SDK's two optional methods,updateRunIfandcompareAndSetState, and the Go, PHP, Rust, and Elixir ports'deleteRunIf(which a run given back needs, below) are default methods that throwUnsupportedOperationException, where Go checks for an optional interface; the client then falls back to a read and a write, as the SDK does, and reports a store that leavescompareAndSetStateorupdateRunIfto the defaults once, asstore, when it first uses it (found by reflection: the method's declaring class isStoreitself).lastRunis a default method too, overlistRuns, which the client never calls.MemoryStoreandSqlStoreimplement all of them. - Records that may grow have static factories (plan D9):
Run.of,StoredJob.of,JobState.of(besideemptyandfromJson),Alert.of,JobSummary.of, andCheckResult.oftake the components the record has today, and keep taking them when a later 1.x release adds one, which changes the canonical constructor. The docs tell a store and an app to build through them, and the built-in stores do; the stored JSON's unknown fields are kept apart from them (a state's in itsextra, a definition whole). - Value types are records:
Run,StoredJob,JobState,Alertand its details,JobSummary,CheckResult,Metrics. Nullable components arenull, annotated with JSpecify's@Nullable(an optional, annotations-only dependency the app never needs at run time, as Spring 7 uses it), notOptional, since a store of the app's own builds these from rows. A state'spendingRecoveryandundeliveredarenullfor a state written before the fields existed, and unknown keys a newer writer added are kept in their places and written back, in anextracomponent holding aJsObject. A queued alert (inundelivered, or in an entry ofsending) and asendingentry read from JSON keep the JSON they were read from beside them (by identity, weakly, since a new component would change the record's constructor), so a field a newer writer added to the alert, its details, or the entry is written back after the known ones and sent with a retry, and an alert of a type this release does not know keeps its details as stored, as the SDK carries queued alerts as plain objects. Lists and maps in a record are unmodifiable copies. - Wire values stay strings:
RunStatus,Condition,AlertType, andJobHealthare small final classes around the SDK's string, with constants for the known values (RunStatus.OK,Condition.OVER_BUDGET) andof(String)for any other, compared by value, not Java enums: stored values come from other writers, a newer SDK adds values that must pass through unharmed, and an enum cannot hold a value it does not name (see Decisions, 9). Aswitchover the constants works as over strings. - A run given back:
job.run(fn, RunOptions.discardWhen(e -> ...))takes a run back rather than judge it when the function throws an exception the predicate answers true for, for an attempt a scheduler gives back without failing (a ShedLock lock not taken, below). It is the Go port'sDiscardWhen, the Rust port'srun_or_discard, and the Elixir port'sdiscard_when:, rule for rule, with their audits' fixes: the running row is deleted withdeleteRunIf, nothing is judged or alerted, the job's state is left as it was (missed and stuck are closed only once the run is known not to be given back), the exception is still thrown, a row a check marked stuck is left as it is, a store withoutdeleteRunIfrecords the run as it ended, and a predicate that throws is reported (discarding <job>) and the run recorded. - Writing a declaration now:
cw.syncJob(name)writes the job declared in this client to the store unless the store already holds that definition (compared as JSON, keys in any order), and says whether it wrote, as the other ports' do; the integrations use it. - Errors are
CronwatchException, unchecked, with akind():INVALID(what the SDK refuses, with its message),STORE(the store's own exception as the cause), andOTHER. A read (jobs(),jobSummary(),runs()) throws it when the store fails;run,start,flush, andfinishnever do (see Runs).
There is no global client, as there is none in the Rust port: an app keeps its Cronwatch where it keeps its DataSource (a field, a Spring bean) and closes it at shutdown. Cronwatch.current() is static, since it answers for the calling thread.
The client never blocks a job on anything but its own store writes. Every read-modify-write of a job's state goes through updateState: holding the job's fair ReentrantLock (the SDK's serial() queue, FIFO), it reads the state, works out the next one, and writes it only when it changed, with version one higher, through compareAndSetState against the version read; a refused write is worked out again from a fresh read, up to 10 times, as in every port. Only store calls happen under the lock; alerts are sent after it is released. A cluster of JVMs is a set of processes sharing one store, as several Node processes are, and compareAndSetState keeps them honest.
The client's own work runs on virtual threads, one per task, from an executor the client owns and shuts down at close(): the recording after a run's function returns, each channel's send, triage, the closing of missed and stuck at a run's start, the shared check, and the interval. A virtual thread costs little, so nothing is pooled, and nothing the app configured (its common pool, its Spring task executor) is borrowed. check() joins the check in flight or starts one, on a thread of its own, and every caller waiting gets the same answer through one CompletableFuture, so a caller that is interrupted neither fails the check for the others nor leaves a job half checked; a throw in the check (a store's) is that check's CronwatchException and the next call starts a new one. start(every) runs the first check after a second and then on the interval (a minute by default, five seconds at least, at most the SDK's 2^31 - 1 ms); each tick asks for a check without waiting on it, as setInterval does, so a tick while a long check runs shares that check and the ticks it outlasted are not run back to back after it; a second start does nothing, and stop ends it.
close() stops the interval, waits for a check under way to end as the SDK's close awaits it (bounded by its own channel, triage, and retry timeouts, so it never writes to a store already closed), waits up to five seconds for the other sends and recordings in flight, then interrupts what is left, and removes the client's shutdown hook (see Runs). No check starts once close() is called: it sets a flag before it reads the check in flight, and check() reads the flag after it puts its check in flight, so either the close waits for that check or the check sees the client closed and throws (it would otherwise run in the caller, as work does after the executor shuts down, against the closed store, and on SQLite open a connection nothing closes again). A close from a thread of the client's own (the check, a channel's send, triage, or an error handler called from one of them, each task marked by a thread local as it runs) cannot wait for the check, which may be waiting on that very thread: it returns at once, and the rest of the close runs on a thread of its own once the check has ended. In a servlet container, where an undeployed app's threads would pin its class loader, that is what the starter and the servlet adapter call when the app stops.
job.run(fn) runs the function in the calling thread, by default. That is where the app's context lives: a transaction bound to the thread by Spring, SLF4J's MDC, an OpenTelemetry span, a security context, a Quartz worker's own thread. It declares the job if needed, inserts the running row (a store failure goes to the error handler and the job still runs), sets the run as current for the thread, sets the MDC keys cronwatch_job and cronwatch_run when SLF4J is on the class path (an optional dependency, compiled against and never required), and calls the function inside try/catch (Throwable). Then:
- What counts as failed. Any throwable fails the run: a checked exception, an unchecked one, an
Error. Anything returned succeeds, as a JavaScript function's return does; there is no Java convention of returning a failure (the Elixir port's{:error, reason}), and a returnedOptional.empty()orfalseis a value like any other. After the recording, the throwable is thrown again unchanged, so the caller's own handling (Spring's error handler, Quartz'sJobExecutionException) sees exactly what it would have without CronWatch. - Recording outlives the caller. Everything after the function returns (the finish, the state, the alerts) runs on a virtual thread of the client's that
runwaits for. An interrupt that reaches the caller while it waits (an executor'sshutdownNow, a request thread a server gives up on) stops the waiting, not the recording: the interrupt status is set again for the caller's own code, and the recording completes on its own thread. On a virtual thread, an interrupt closes a socket the thread is blocked on, so a JDBC write made in the caller's thread could be cut part way by it; this is why the write is never made there. It is the Rust port's rule for a dropped future, which a Java thread meets through interruption. - A thread interrupted inside the function. Java cannot kill a thread (
Thread.stopthrows since Java 20), so a run's function ends only by returning or throwing. An interrupt is a request the function honours by throwingInterruptedException(from a sleep, a blocking queue, an interruptible channel), which is a failed run like any other throw, writtenInterruptedException: sleep interrupted, with the interrupt status restored before it is thrown again, as well-behaved Java code must. A function that swallows the interrupt and returns has succeeded, as the function decided. - A JVM that stops inside the function. A process can end while a run is open:
System.exit, aSIGTERMfrom a container's orchestrator, a Spring context closing at shutdown. The client registers one shutdown hook (unless built withnoShutdownHook()), which, when the JVM begins to stop, marks each run still open in this process failed (Shutdown: the JVM stopped while the run was in progress), writing each throughupdateRunIfonly over a row stillrunning, so a function that finishes on its own during the shutdown wins and nothing is judged twice; it spends at most five seconds, all runs at once, and sends the alerts it can in what is left (see Decisions, 8). This is the Elixir port's monitor and the Rust port's decision 4 (a run whose process is gone is failed at once) as far as a JVM allows: aSIGKILL, anOutOfMemoryErrorthat stops the JVM, or a machine lost runs no hook, and that run is reported stuck at its timeout, as a killed Node process's is. - Timeouts. The SDK aborts a signal at the job's timeout and lets the function decide; it never stops the function, and a function that runs on is marked stuck by a check. So does this port by default: a timer of the client's flips the run's flag at the timeout,
job.cancelled()reads it, and theonCancelcallbacks run on the timer's thread.RunOptions.interruptAtTimeout()also interrupts the running thread at the timeout (the only stop Java offers), and a run that then ends by throwing is recorded exactly as a check would mark it (statustimeout,Still running after 30m; marked as timed out), so the alert is the SDK's stuck alert, sent sooner, as the Elixir port'skill_at_timeoutis. It is opt in, since an interrupt can land in the app's own code in the middle of anything. - Imports.
cw.recordRun(run, evaluate)is the SDK'srecordRun(): an evaluated import is written first asrunning, with a finish time of when it was written (not its own; none for an import that is itself still running, which times out by its job's timeout as any run does), until its pending marker and its finish land, so a concurrent check does not mark it stuck; one whose finish is still not written five minutes later (Evaluate.isStranded, the SDK'sisStranded, replayed fromconformance/health.json) is closed by the next check as timed out, its own finish time and duration kept and the errorImported, but its finish was never written; marked as timed out(with the run's own error on the next line), and alerts as a stuck run; a later retry that succeeds still recovers it. Of the fields the SDK checks for callers without its types, onlystatuscan be wrong in Java (aRunStatusanother writer named, whichRunStatus.ofkeeps), so it alone is refused, with the SDK's message, before anything is written. current().Cronwatch.current()answers the calling thread's run from aThreadLocal, set when the function starts and removed infinallywhen it ends, so no pooled thread keeps a run that has finished. A thread the job starts has no run of its own, as a thread started in the SDK's function has noAsyncLocalStoragestore;JobContext.wrap(Runnable)andwrap(Callable)carry it across, and when Micrometer'scontext-propagationlibrary is on the class path (it is in every Spring Boot app with Reactor or Micrometer), the core registers aThreadLocalAccessorfor it, so Spring'sContextPropagatingTaskDecorator, Reactor's automatic context propagation, andContextSnapshotcarry the run to other threads with no code.ScopedValuewas weighed: it is the better tool (immutable, bounded to a call, inherited byStructuredTaskScopeforks), but it is final only from Java 25 (JEP 506), above the floor; it replaces theThreadLocalwhen the floor reaches 25 (see Decisions, 6).- HTTP answers. A function that returns a
java.net.http.HttpResponsewhose status is 400 or more fails the run withHTTP <status> <reason>, the reason from RFC 9110's table (the JDK's response carries none), as the SDK fails a run for a fetchResponse; so does a SpringResponseEntityof such a status when Spring is on the class path, found by class name so the core needs no Spring.
job.start(StartOptions), job.resume(runId), and cw.resumeRun(name, runId) are the SDK's start(), resume(), and resumeRun(), and return a RunHandle: id(), job(), startedAt(), isActive(), log, metric, flush(), finish(), finish(result), and fail(throwable), following client.ts and the Go port's handle.go rule for rule: a start with an id holds a per-client entry keyed by the job and the id while it reads the store and inserts (a second start with the same id at once waits, then finds the stored run and answers a handle on it); finish reads the stored run again, joins its output with the handle's (capped) and merges metrics (the handle's win); flush writes only over a row still running and of this job; the store never fails out of start, resume, flush, or finish (failures go to the error handler as recording <job>, starting <job>, resuming <job>, flushing <job>, or finishing <job>); and a store that fails during finish leaves the handle active, lines kept, so it can be called again. flush and finish do their writing on a thread of the client's and wait for it, as run records, so a caller interrupted part way leaves it to complete rather than lose the lines flush had taken. A handle is AutoCloseable only in the sense a linter asks for: close() does nothing, since a handle left unfinished is what the stuck check is for, and a handle garbage-collected while active records nothing.
A run is judged once, however many processes finish it: the finish is written only over a stored row still running, else over one still timeout, through updateRunIf, one conditional UPDATE, and only the process whose write lands evaluates. insertRun refuses an id already stored. The SDK's multi-process scenarios (finish once) run against the memory store and against SQLite, Postgres, MySQL, and MariaDB, each process a client of its own over one database.
Deliver.NOW (the default) sends each alert from the process that produced it; Deliver.AT_CHECK queues it in the job's state (undelivered, at most 20, the oldest dropped first and reported) for the next check in a process that delivers now. An alert some channel refused is queued the same way, carrying owed (the keys, from channelKeys, of the channels that refused it: a channel's name, then #2, #3 for one of the same name), and retried once per check, oldest first, to those channels only, while they are still configured (an entry without owed, from an older release or never sent, goes to every channel configured now, and one whose owed channels are all gone is done); lastAlertAt moves only when some channel accepted an alert on that attempt, and a channel is never given owed, which is kept among the alert's stored keys rather than as a record component; one that no longer describes the job (staleAlert) is dropped; one check spends at most 20 seconds retrying across all jobs. Triage is tried once per alert. Alerts are held in the job's state by the write that opens their condition, before any is sent (the SDK's outbox): in sending (SendingAlert, each with a five-minute lease) while this process sends them, or in undelivered with Deliver.AT_CHECK; the result of the send takes them out of sending, and a check moves any whose lease ran out (their process stopped part way) to undelivered, so they are sent once, or twice if a channel took one just before its process died. The copy held in state is trimmed (heldAlert): the run's error keeps its first 2048 UTF-16 units and its output its last 2048, the message its first 4096, each with a line saying how much was left out, and only a text more than 64 units past its cap is trimmed, so a held copy queued again is never trimmed twice; the first send uses the alert as composed, and only a retry sends the trimmed copy. JobState.sending is typed, not kept among the unknown keys, so it is written once; the constructor without it stays for code that made states before it. JobState.underFloor, the metrics under their floor at the last successful run, is typed the same way, and the constructor without it stays too.
Each channel sends on a virtual thread of its own, waited on for 15 seconds, and triage for 25. Past its time the send is cancelled: its HttpClient exchange is cancelled through its CompletableFuture (which aborts the request since Java 16) and its thread interrupted, so the next alert is sent to the channel as the SDK sends it. A channel of the app's own that blocks in code that ignores interrupts keeps its virtual thread until it returns, harmlessly, as the Go port's abandoned sends did; the Channel documentation says so.
The error handler is the SDK's onError: onError((where, throwable) -> ...), by default a System.Logger named dev.cronwatch at ERROR with the SDK's text ([cronwatch] recording nightly-report: ...). System.Logger is the JDK's own logging front, which goes to java.util.logging unless the app routes it elsewhere, and Spring Boot's logging already takes java.util.logging into Logback or Log4j 2, so the line arrives where the app's other logs go with no dependency. Console, the default channel, writes the SDK's line to System.out for a recovery and System.err for anything else, where the SDK writes console.info and console.error, since a crontab's mail and a container's log collector read those streams (see Decisions, 11). The in-memory store's warning and start() with Deliver.AT_CHECK's warning go to the same logger with the SDK's text.
dev.cronwatch.alerts holds the SDK's channels, request for request: Slack, Discord, the webhook, Resend, Postmark, SendGrid, Mailgun, SES, Twilio, Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, and New Relic, each made from an options record with the SDK's option names (Resend.channel(ResendOptions.builder().apiKey(...).from(...).to(...).build())), and the SDK's refusal in Java's words (Resend needs an apiKey) from build(). A boolean is given as the SDK names it, with the SDK's default when left out. The requests are the SDK's: the same URL, header names, and values, and body written by Js, so the bytes are identical; the same stable alert id (the first 32 hex characters of SHA-256 over job, type, and time); the same error text. The webhook's body is {"schema":1, followed by the alert's own fields, as the SDK's; its extra headers are an ordered list of pairs.
Every request goes through Post (internal/post, not exported), a transport-free port of the Go port's internal/post, the Rust port's alerts::post and the Elixir port's Alerts.Post, with all three audits' hardening, over a Transport interface (one POST: a Request of a URL, headers in order, and a body; it answers a Response, a status and a body handed over a chunk at a time, so Post holds every transport to the deadline and the cap). Post calls the transport on a virtual thread of its own and waits for the answer's head and then its body on two futures, within one deadline, so the deadline holds whatever the transport does. The default transport is JdkTransport, over one java.net.http.HttpClient per Cronwatch client, built with HTTP/1.1, Redirect.NEVER, no proxy, a virtual-thread executor, and a ten second connect timeout: the client makes it on its first send and closes it (shutdownNow) with itself, and a channel reaches it through ChannelContext.transport(), triage through Triage.Context.transport(). An app that wants OkHttp, Apache HttpClient, a proxy, or its own trust store writes a transport (a dozen lines) and gives it on the channel or the client. The questions this port had to answer about the JDK's client, and the answers:
- The 1 MiB cap as the body arrives.
HttpClienthands a body to aBodySubscriber, aFlow.Subscriber<List<ByteBuffer>>that asks for each chunk itself.Post's subscriber asks for one chunk at a time, keeps at most 1 MiB, and on the chunk that reaches the cap keeps its part, cancels the subscription (which closes the connection) and completes with what it has, whatever the status and however the answer is framed (acontent-length, chunked, or up to the connection's close). Nothing past the cap is ever buffered. Java 25'sBodyHandlers.limitingwas weighed: it fails the whole answer with anIOExceptionpast its capacity, where the SDK keeps the status and the first 1 MiB, and it is above the floor, so the subscriber is the port's own, some forty lines. Noaccept-encodingis sent andHttpClientdecompresses nothing, so a gzip answer cannot grow in memory. - One deadline for the whole request: ten seconds inside the channel's own, connecting, sending, and reading the answer.
HttpRequest.timeoutcovers only the wait for the answer's head, so the deadline isPost's: it sends withsendAsyncand waits on the future for what is left of the ten seconds, then cancels it. Past it before an answer isThe operation was aborted due to timeout, fetch's message; past it while the body arrives is the answer with no body, the status kept (the handler records the status as soon as the head arrives). - A redirect is refused, not followed:
Redirect.NEVER, and a 3xx is an answer outside 2xx like any other, so credential headers never go where it points. - TLS is always verified: the default
SSLContext, with the JDK's trust store and its host name check (a certificate for an IP address checked against the address), never turned off; an app's own trust store is its own transport. - The URL is read by a port of the WHATWG rules the Go port's
internal/postapplies (a space inside is encoded and posted,javascript:is refused asnot javascript:, a user name is refused), not byjava.net.URI, which is RFC 2396 and refuses a space or reads others differently. It must behttporhttpswith a host, and is never quoted in an error, since a webhook URL's path is its credential. The parsed URL is then written out and given toHttpClientas aURI, and the host theURIreads back must be the host the port read, or the URL is refused, so what the URL names and what is reached cannot differ. A host outside ASCII is refused asnot this URL, sincejava.net.IDNis IDNA 2003 and WHATWG's is UTS 46; an IPv6 host with a zone is refused, as fetch refuses it. - Only the URL's origin appears in any error: every exception the transport throws is rewritten to the origin and the exception's simple name and message, with the whole URL and query cut out of the message first (
HttpConnectTimeoutExceptionandConnectExceptionquote nothing, but a transport of the app's own might). - Headers.
HttpClientrefuses to let a caller sethost,connection,content-length,expect, andupgrade, and sets them itself; none is one the SDK sets, and an app's webhook header of those names is dropped and says so in the documentation. It sendsUser-Agent: Java-http-client/<version>unless one is given, so the transport sends none of its own where fetch sends its own, and triage sendscronwatch-java/<version>. Credentials are trimmed of the spaces and newlines a paste leaves; a header value with a CR, LF, or NUL left inside is refused, naming the header, never its value (HttpClientwould refuse it too, with a message that quotes it). - Every secret the channel holds is cut out of a quoted answer before the answer is cut to 200 UTF-16 units on a code point.
Phase 2 confirmed each of these against real local servers on JDK 21 and 27, so JdkTransport is kept and the Elixir port's fallback (a small HTTP/1.1 client over SSLSocket) was not needed. The body subscriber (JdkTransport's Pull) asks for one chunk each time Post asks for one, and closing the answer cancels the subscription: an answer of 64 MiB, framed by content-length, chunked, or up to the connection's close, is read to exactly 1 MiB, after which the server can write no more, so the cancel closes the connection. The JDK sends no accept-encoding and decodes nothing, so a gzip bomb is read as its compressed bytes. A redirect is answered, never followed, by all fifteen channels, and the other origin sees nothing. The deadline holds before an answer (a server that never answers fails at the deadline with fetch's message) and while a body drips in (the status kept, the body none). An untrusted certificate fails the handshake (SSLHandshakeException, the path never quoted); a trusted certificate for 127.0.0.1 is taken at that address and one for 127.0.0.2 refused there, so the host check runs for an IP address (the JDK sends no SNI for an address, and that turns nothing off). The URL's own spelling reaches the JDK, except that what WHATWG leaves in a path or query and java.net.URI cannot hold ([, ], ^, |, {, }, a backtick, and a % not followed by two hex digits) is sent percent-encoded, and a URL whose host the URI reads otherwise (a host name with an underscore, which it takes for a registry name) is refused as not this URL. The JDK writes its own Content-Length, Host, and User-Agent first and then the request's headers sorted by name without regard to case (it keeps them in a TreeMap), names in the case given; a test pins it. A transport's exception is written as its simple class name and message and each cause's that adds something (ConnectException, IllegalStateException: transport broke), with every spelling of the URL taken out. Twilio texts every number at once, a virtual thread each, and follows the SDK's segment fitting and partial-delivery report. The tests replay every case of conformance/channels.json through a recording transport, and a hardening suite runs the channels against real local servers (raw ServerSockets and SSLServerSockets in the tests, so no server is a test dependency and one can misbehave as a test needs, with certificates made at run time by the JDK's keytool, no key committed): a redirect refused by all fifteen, the deadline before an answer and while a body drips in, a large answer read to exactly 1 MiB in each framing, the URL and header refusals, only the origin in an error whatever a transport quotes, TLS verified, the headers the JDK sends and their order, a secret straddling the cut, a lone surrogate on the wire, and Twilio's partial delivery through a client.
Anthropic.triage(AnthropicOptions) is triage/anthropic.ts without an Anthropic client, as the Go, PHP, Rust, and Elixir ports do it: the Messages API is one POST, so it sends the request the official client makes (conformance/triage.json's wire cases): the same URL with ?beta=true, anthropic-version, anthropic-beta for the fallback beta, x-api-key, and the body byte for byte, with cronwatch-java/<version> as the user agent. The defaults are the SDK's (model, effort, tokens, the prompts, the <job_data> fencing, the fallbacks unless noFallbacks()); maxTokens(0) is sent as 0, as the SDK sends it. One attempt, no retries, within the smaller of 24 seconds and what the client allows. The key is apiKey(...), else ANTHROPIC_API_KEY, and the base URL baseUrl(...), else ANTHROPIC_BASE_URL, else https://api.anthropic.com, both read each time triage runs (see Rules), so a missing key is that triage's failure, reported as triage for <job>, not a refusal when the client is built. It sends through the client's transport unless its options name one. The official Anthropic Java SDK (com.anthropic:anthropic-java) was weighed: it brings Jackson, OkHttp, and Kotlin's standard library into an app for one POST, and its request bytes are its own, so it is left out, as every port since PHP left the official client out. The tests replay every case of triage.json.
The pg_cron source is PgCron.source(dataSource, PgCronOptions) in dev.cronwatch.pgcron, over a Postgres DataSource only, sources/pgcron.ts line for line as the Go, Rust, and Elixir ports' are: the SDK's SQL, arrays of cursors passed as text literals (?::text::bigint[]), settings read from pg_settings, run ids pgcron:<prefix><runid>, the twenty newest runs copied quietly on first sight, the hold for a run queued without a start time, and jobs declared again only when their definition changed. The SDK's jobs option is jobs, jobIds, and pick, as in the other typed ports. The tests replay conformance/pgcron.json, port the SDK's source tests against a fake cron schema, and run against a real pg_cron when CRONWATCH_TEST_PGCRON is set, as the Go, Rust, and Elixir jobs do.
The API is PgCron.source(dataSource, PgCronOptions.builder()...build()), with the PgCronJob record its options are given. The SDK's helpers (PgCron.schedule, PgCron.jobName, PgCron.run over a PgCronRow, and HOLD_MS) are package-private since 0.11, as the TypeScript entry point stopped exporting them (plan D20 and D25); the tests replay them. Each query takes a connection of its own from the data source, in autocommit, so the source never reads inside a transaction the app has open and never commits or rolls back. Where it is between checks lives in the source object, behind a lock, so a new client finds its place again from the store. pick with jobs or jobIds, and options that set a schedule or timezone, are refused at build() (the other ports let pick win, or drop the keys, silently); from an options function such a job is reported and not declared. Whether a job changed is judged by JobOptions.describe(name), the definition the options declare, as the Go port's DescribeJob.
MemoryStore is stores/memory.ts: one lock, copies in and out, names sorted by UTF-16 code unit order (String.compareTo, which is JavaScript's default sort, where the byte-order ports needed care), the same prune rule.
SqlStore keeps the same three tables in the app's database through JDBC: SqlStore.sqlite(dataSource), SqlStore.postgres(dataSource), and SqlStore.mysql(dataSource) (MySQL and MariaDB), or SqlStore.of(dataSource), which asks DatabaseMetaData.getDatabaseProductName(); .prefix(p) sets the table prefix (the SDK's rule and message). The app brings its driver and its pool (HikariCP in every Spring Boot app); the store takes a javax.sql.DataSource so it shares the app's credentials and pool, and no driver is a dependency of any published artifact. Every statement is written from a dialect (internal/sql), and the tables are made when the client first calls init. JDBC over a store per driver API because it is the one interface to all four databases, part of the JDK, and how nearly every Java app already holds its database; an app on jOOQ, JPA, or R2DBC gives the store the DataSource underneath.
- The schema, byte for byte. The SQLite and Postgres
CREATEstatements arestores/sql.tstext for text: the sameCREATE TABLE IF NOT EXISTStext forjobs,runs(withseq BIGSERIALfirst on Postgres), andstate, the same two indexes, sosqlite_masterreads the same whoever created the tables;INTEGERorBIGINTtimes,TEXTorJSONBJSON,rowidorseqbreaking ties, names sortedCOLLATE "C"on Postgres;initunderpg_advisory_xact_lock(hashtext('cronwatch:<prefix>')), in one transaction of the store's own, so many processes can start at once.sql.tswrites its statements with?and numbers them for Postgres; JDBC takes?on every database, so the statements aresql.ts's text before the numbering, and the other dialects' text is the same. The MySQL dialect is the PHP, Go, Rust, and Elixir ports' (MySQL 8.0.13 or MariaDB 10.6 or newer:utf8mb4_bin, JSON asLONGTEXTholding the SDK's bytes, never MySQL'sJSONtype,ON DUPLICATE KEY, the prune's newest start per job as a derived table, compare-and-set from version 0 in two steps, a landed write read back when the server counts changed rather than matched rows,triggerasVARCHAR(255)), copied frompackages/go/sqlstore/sql.go. On MySQL and MariaDB each statement runs as on Postgres, on a connection of its own in autocommit. Both drivers count matched rows by default; the store reads back a conditional write that answered 0 anyway (a run's update, and the compare-and-set from version 0 in two steps), so a connection withuseAffectedRows=truecannot make a write that landed read as refused, which a test runs both ways. A trigger longer than 255 characters is cut on a code point. On every dialect the version compare-and-set reads is aCASEthat tests the JSON type before any cast, the SDK's text. - JSONB without a cast. node-postgres sends parameters untyped and Postgres infers
jsonb; pgjdbc sends asetStringtyped asvarchar, which ajsonbcolumn refuses. The store binds JSON withsetObject(i, text, Types.OTHER), which pgjdbc sends with no type, so Postgres infersjsonbas it does for node-postgres: the statements keepsql.ts's text, with no::jsonbcast (the Elixir port's one difference) and no driver class in the store's code (the Rust port bound a type of its own). A JSON column is read withgetString, and parsed withJsin the order Postgres gives the keys. Phase 2 confirmed it against pgjdbc 42.7.13: the parameter is inferred asjsonb(a test readspg_typeof), so the statements keepsql.ts's text with no cast. - SQLite. The store holds one connection for its statements, in turn, taken from the
DataSourceonce and kept, as the SDK's, Go, and Rust stores hold one: the pool's connections would each need the pragmas, an in-memory database is one per connection, and SQLite has one writer at a time anyway. That connection is put in WAL mode with the SDK's retry of a busy database while switching (busy.ts), thenbusy_timeout5000 andsynchronousNORMAL. xerial'ssqlite-jdbcis the driver the tests use and the README names; it carries native SQLite builds for the common platforms. - Not the app's transaction. Each statement runs on a connection the store takes from the
DataSourceitself, in autocommit, so the store's writes never join a transaction the app has open and a failed run does not vanish with the rollback it caused. Spring binds a transaction to a thread through itsDataSourceUtils, which the store never calls; aDataSourcewrapped in Spring'sTransactionAwareDataSourceProxywould hand the store the app's connection, so the starter unwraps it, and the README says to give a plain one. The test "a run surviving the app's rollback" holds it, as it does in Rust and Elixir. - Foreign rows. A row of another shape than the SDK writes, valid JSON all the same, is read as the SDK reads it rather than failing every read it is part of, with the Go, Rust, and Elixir audits' fixes from the start: metrics that are not all numbers keep the ones that are, a queued alert that is not an alert is dropped, times near the limits saturate, a duration is held from 0 to 2^53 - 1, a state's version that is not a whole number from 0 to 2^53 - 1 counts as 0 (in the SQL of every dialect, the client, and the memory store alike), a MySQL write whose answer was lost counts as written when the stored row equals what was sent, SQLite text that is not UTF-8 reads with U+FFFD, and a time before the year 1 or after 9999 is written in words wherever the SDK writes a date (see Keeping in step).
The drivers are test dependencies only: org.xerial:sqlite-jdbc, org.postgresql:postgresql, com.mysql:mysql-connector-j and org.mariadb.jdbc:mariadb-java-client, in the core's test scope, so none reaches an app. dev.cronwatch.storetest is the SDK's store-conformance.ts for a store of the app's own, the Go and Rust ports' storetest, with one promised entry point, StoreContract.run(store) (plan D28); the store.json replay (StoreReplay, with its foreignVersion cases, each state text written raw), the finish-once scenarios (FinishOnce), and the foreign rows (ForeignRows), in the package before 0.11, are the port's own tests since, in the same package under src/test. The kit depends on nothing: it throws AssertionError, which every test framework reports. The core's own tests run it per database, with a check over a run that started at the lowest BIGINT, the finish-once scenarios over several clients on one database, a client end to end, the rollback test, and rows of another shape: SQLite always, Postgres, MySQL, and MariaDB when CRONWATCH_TEST_PG, CRONWATCH_TEST_MYSQL, and CRONWATCH_TEST_MARIADB are set, each test on tables of its own prefix, dropped at the end. A node-compat test has the built SDK and the Java store replay the same calls into two SQLite files and compares what each reads of the other's and every column's bytes and SQLite type, and has a Node client and a Java client take turns on one file and one job's state version.
java.time carries the IANA database in the JDK itself (tzdb.dat), so zones work the same in a distroless image with no /usr/share/zoneinfo, on Windows, and in a jlink image, with no dependency. The croner port asks ZoneRules for the offset at an instant and the transitions around one (croner's fromTZ rule is the port's own). Zone names are matched without regard to case, as Intl does, against ZoneId.getAvailableZoneIds() (ZoneId.of is case sensitive), and fixed offsets (+05:30) are zones too, as croner accepts them; ZoneId.of's own forms that are not IANA names (UTC+01:00, GMT0 spelled its way, the three-letter SHORT_IDS) are refused unless the SDK takes them, which schedule.json holds. Without a timezone, a cron is read in ZoneId.systemDefault(), which the JVM takes from TZ, /etc/localtime, or -Duser.timezone. The database is as new as the JDK's last update, or the tzupdater tool's; the README says so. A library reading the system's database, or bundling one, was weighed and not needed.
Secret redaction uses the SDK's patterns, copied verbatim as JavaScript source into internal/output and run by internal/jsre, the Go port's engine as carried over and hardened by the Rust and Elixir ports, over the string's own UTF-16 code units (no conversion, since a Java string is one), so a quantifier counts an emoji as two, \s is JavaScript's set, \b, and /i are ASCII-only as they are in JavaScript without the u flag (letters fold as JavaScript's Canonicalize, the Elixir audit's fix), and every one of conformance/output.json's 204 redaction cases gives the SDK's bytes. It refuses what it does not implement rather than read it as something else.
java.util.regex was weighed and left out for both redaction and stored patterns, though it counts in UTF-16 units and has lookaround: its \s and \b are its own, $ matches before a final line terminator, [^] is an error, its case folding and its reading of some escapes differ, it has no step limit (a runaway pattern can only be stopped by a CharSequence that counts its reads, a trick that does not bound its memory) and it recurses on a long alternation until a StackOverflowError. The Python port showed the SDK's patterns can be rewritten for another engine, but a stored matches /.../ pattern is JavaScript source that no rewrite can foresee, and one engine in six ports means one answer.
The Rust audit's bounds come with the engine, as rules rather than findings: a pattern longer than 4096 characters or with groups nested more than 100 deep is not read; a match gives up past 512 frames of recursion (a JVM thread's default stack holds it with room to spare, and a virtual thread's grows to the same limit); and a stored expect pattern counts its steps (each attempt at a node, and each code unit a repeat scans) and gives up past 50 million, the Rust port's budget, which phase 1 measured well under the two seconds the draft set as the line, so it stands (see Decisions, 18). A stored pattern that runs out of its bound fails, with the ordinary Output did not match /source/flags: a pattern is slow because it is searching an output it does not match, so failing is the answer an engine that finished gives in nearly every case, and a guard that passed when it could not tell would hide the broken job it is there to catch (the Rust port's decision 36, the rule every bounded port keeps). The SDK's own redaction patterns are bounded and run without a budget. The JSON reader refuses nesting past 256, as the Rust and Elixir ports' do, for the same answers on the same inputs; objects are read into an order and a map of values, never by a scan per key, the Elixir audit's fix for a JSON object of many keys.
cw.routes(RoutesOptions) is the SDK's cw.routes(): a Routes whose answers are routes/index.ts line for line and whose pages are routes/html.ts and routes/timeline.ts byte for byte, the CSS, the app shell, the icons, the sign-in form, and the <wbr> after a name's separators included; internal/web is the Rust port's web/, carried over. The options are a builder with the SDK's names, in the three-way form: token(t), noToken(), or neither (reading CRONWATCH_TOKEN when the routes are made, as the SDK reads it, a value that is empty or only whitespace as JavaScript's trim sees it counting as unset, given or read; any other is used untrimmed), basePath, origin (read as new URL(value).origin reads it, by a port of the Go port's routes_origin.go; anything but http or https is refused with the SDK's message) and trustProxy(). A HEAD is answered as its GET would be, the status and headers without the body, on the pages and the API's reads (not /api/check, so it never runs the check), the body dropped in Routes.handle itself so every adapter answers alike.
The core is framework-free: Routes.handle(web.Request) answers a web.Response, over plain values (the method, the request target as sent, the headers as sent, a body read only when a route wants it, whether it came over TLS, and where an adapter found the mount), as the Rust port's Routes::handle and the Elixir port's Web.Routes are. Every framework is an adapter over that one method, so the golden replay tests the same code every adapter runs:
- The JDK's own server, in the core:
WebServer.mount(httpServer, "/cronwatch", routes)adds a context to acom.sun.net.httpserver.HttpServer, for a plain program with no framework and forwebserver, the base path being the context's. The target comes fromHttpExchange.getRequestURI()'s raw path and query, so a%2Fin a job name stays one. The routes read the store, so the README gives the server an executor of virtual threads (the JDK's default runs every exchange on its one dispatcher thread); aHEADis answered without a body. - Servlet,
cronwatch-servlet:CronwatchFilter(endpoint, path), a JakartaFilterthat answers the requests under its path (/cronwatchby default, within the context) and passes every other one down the chain; it is given its path rather than reading its mapping, which a filter cannot learn. The target isgetRequestURI(), which the container leaves undecoded, with the query fromgetQueryString(); the base isbasePathwhen given, else the context path and the filter's path. A filter, not a servlet, so it can sit ahead of the app's own servlets and security (see Decisions, 17).CronwatchServlet(endpoint)is the handler's (and would serve the dashboard mapped at/cronwatch/*, its base the servlet path). - Spring MVC and WebFlux, through the starter (
dev.cronwatch.spring.web, apart from the client bean and the scheduler integrations): on a servlet app the starter registersCronwatchServletFilter, the filter as a bean with an order, which Spring Boot maps to every request (so noFilterRegistrationBean, whose home moved in Boot 4), atcronwatch.web.path(default/cronwatch); on a reactive app it registersCronwatchReactiveFilter, aWebFilterof its own that reads the request's raw path and headers and runsRoutes.handleon a virtual thread, where the body is joined, only when a route wants it, to at most 1 MiB and one byte throughDataBufferUtils.join, since the routes read the store and a Netty event loop must never block. Both are at order -110 (cronwatch.web.order), ahead of Spring Security's chain at -100, and use the app'sCronwatchbean; aRoutesbean of the app's own replaces the starter's.
What a request says is read as the SDK reads a fetch Request, carried over from the Go, Rust, and Elixir ports: the path from the target as sent, the query as URLSearchParams parses it, headers joined as Headers.get joins them and compared as Latin-1 (a server hands a header over a byte a character, as fetch's Headers hold it; the Host and the target are read back as the UTF-8 they were sent in, through Adapters.target), the body as request.json() and request.formData() read it, read only once a route wants it and capped at 1 MiB and one byte (413 past it, as every typed port answers), and the origin from the scheme the adapter reports and the raw Host. A servlet body a filter ahead of the dashboard already read as a form (Spring's FormContentFilter or HiddenHttpMethodFilter, or anything that called getParameter) leaves nothing to read; the adapter then takes the container's parsed parameters when its own read comes back empty, as the Elixir port takes body_params behind Phoenix, and the golden replay runs behind Spring MVC to hold it.
- The development token. With no token set, in development (see Rules), the dashboard makes one (32 bytes from
SecureRandom, base64url) and announces it once perRoutesonSystem.out, whereconsole.infowrites it (a closed or failing output is ignored, asconsole.infonever throws, the Rust audit's case). The line shows the host only whenoriginis set or the request's origin is loopback (localhost,*.localhost, 127.0.0.0/8,[::1]), the SDK'sdevelopmentSignInLineandisLoopbackOrigin, and the origin is read as a URL by the origin reader before its host is tested, as the Rust audit found it must be: aHostofevil.example/.localhostorlocalhost:1@evil.exampleis not loopback, and a string test took both for it. Tomcat and Jetty refuse some such headers and the JDK's server refuses fewer, so the test is the only guard. The token is made when the routes are made, as the SDK makes it, so two first requests at once agree on it, and it is announced once (anAtomicBoolean);Routes.token()is the token in use, the generated one included. Without system randomness (SecureRandomthrowing), none is made and the routes stay locked, as the Rust audit settled. - Carried over from the audits. A
Hostoutside ASCII longer than 1024 bytes is not punycoded or read as a URL; a body that could not be read to its end is read as none, never as the part that arrived (for=7dcut short is not a 7 ms silence); a request whose client went away reports nothing (every adapter drops a failed write of the answer); a body nested deeply reads as none; huge durations and a foreign row's far times are answered; the token, the cookie, and the bearer are compared in constant time withMessageDigest.isEqualover their bytes; every value on the pages is escaped.
The style sheet, scripts, and icons are the SDK's files copied into cronwatch/src/main/resources/dev/cronwatch/web/assets: scripts/make-dashboard-icons.mjs writes the icons there beside the other ports' copies, and packages/ruby/test/web/golden.mjs writes style.css, app.js, and sw.js from its captures, and both, run with --check by npm run check:conformance, fail when a copy differs from the SDK's. They are read from the jar once, when the routes first serve one.
GoldenTest replays the gem's fixture, packages/ruby/test/web/golden.json (105 captures of the SDK's routes, the HEAD reads among them over a fixed seed, PNGs as base64, with the placeholders <library>, <language>, and <version> in GET /api's answers filled in with the Java values), five ways: straight into Routes.handle (all 105), and through real servers with the base path found from the mount: the JDK's server (both in webtest's own tests), the servlet filter under an embedded Jetty 12 (cronwatch-servlet's tests), and, in the starter's tests, a Spring Boot app on Spring MVC under Tomcat (behind Spring's HiddenHttpMethodFilter, which reads a form post's body first) and one on WebFlux under Netty. Every status, header, and body matches, content-length and what each server adds itself (its date, server, connection, and keep-alive) aside, with two things each server does before the routes are reached: every one of them refuses the two captures whose target is not a URI (/cronwatch/jobs/%zz and its API twin) with a 400 of its own, which the replay holds them to, and Tomcat and Jetty write a content-type back without the space after its ; (text/html;charset=utf-8, the same media type), which the servlet replays read that way. The three captures whose path is outside /cronwatch (/cronwatchapi/jobs, /api/jobs, /cronwatch-x/api/jobs) are matched byte for byte straight into the routes; through a server only their 404 is held, since a server mounting the dashboard at /cronwatch need not route them to it. The routes, the servlet filter, and the starter's filters decide what is under the base on the path with its dot segments resolved (Adapters.under), so they agree. The replay, the seed, and a raw HTTP/1.1 client over a socket (the JDK's client will not send a Host of the test's choosing) are webtest (cronwatch-webtest, package dev.cronwatch.webtest), a module of the build that is never deployed, which the servlet's and the starter's tests depend on; a module rather than the core's tests jar, since a release (and any build that skips the tests) would otherwise have to build or publish that jar. The SDK's routes tests are ported as the Go, Rust, and Elixir ports have them (the token, the cookie, the forms, the cross-site check, the origin and the forwarded headers, the silence durations, and the 64 character cap, the body cap, the app shell, the base path from the mount, the audits' cases), and what depends on the environment (locked, the development token and its sign-in line, a handler without a secret) runs in child JVMs, each with its own variables, since Java cannot change its own environment. A behaviour change lands in TypeScript first, and TZ=UTC node packages/ruby/test/web/golden.mjs regenerates the fixture for every port.
job.handler(fn, HandlerOptions) is the SDK's handler(), for a platform cron that calls a URL (a Kubernetes CronJob with curl, Render, Fly.io, Cloud Run jobs behind a scheduler): framework-free like the dashboard (Handler.handle(web.Request)), with the same adapters (WebServer.mount, new CronwatchServlet(handler); the starter's cronwatch.handlers.* properties are not built, and an app registers the servlet itself). It runs fn(JobContext, web.Request), a HandlerFunction that returns the run's value (see Decisions, 42), as a recorded run with the trigger "handler" for each request whose Authorization carries the secret as a bearer, read as the dashboard's routes read it (the scheme in any case, then any whitespace) and compared in constant time; the secret is the option's, else the client's (CRON_SECRET by default, read when the client is built, a value that is empty or only whitespace counting as unset; a blank secret(...) falls back to the client's, and a blank cronSecret(...) means none); noSecret(), or the client's noCronSecret(), lets anyone run the job. With no secret and no opt-out it answers 503 with the SDK's JSON unless CRONWATCH_ENV or APP_ENV names development: the builder's environment(...) (the Spring starter's active profile) makes the dashboard's development token but never opens a handler, since a profile is a guess and a dev profile left on in production must not let anyone run a job; a wrong or missing bearer is 401. A run is answered with {"ok","job","run","status","durationMs"}, 200 or 500, and the error's first line only when a secret was required, with the headers of the SDK's json(). The function may return a web.Response of its own, which is passed through and fails the run at 400 or more with HTTP <status> <reason>. A throw is a failed run answered 500, as the SDK answers a throw; an Error is recorded and thrown again, as every run's is, and an InterruptedException leaves the request thread's interrupt status set. The handler answers with the run as recorded, handed to it by the run itself, rather than reading it back from a store that may be down. The function runs in the request's thread; an app that wants the job to outlive the request starts it on its own executor. An AWS Lambda adapter (aws-lambda-java-core) is made only if someone asks, as the Go port's Lambda was the only one made.
The SDK has none; these follow the Go, Rust, and Elixir ports', whose shared rules (declaring entries as jobs, several entries of one name on different schedules being one job without a schedule, tags for the integration and the app, jobs gone declared again without their schedule, workers elsewhere declaring from the store's definition, schedules checked against the scheduler's own fire times, retries as the gem's Sidekiq rules) are dev.cronwatch.bridge, carried over from the Rust port's bridge with every audit's fixes from the start: unschedule reads what is declared again under the declaring lock and writes every declaration before it returns; a declaration's write is held to 30 seconds, and a sync an integration starts itself to 30 more; fallback reads the store before it declares and declares nothing when the read fails, and a job declare has declared meanwhile wins; a throw in one declaration's write is that declaration's failure and never leaves the writer marked busy; a declaration's write whose caller gave up does not keep waiting on a hung store; a job forgotten (the dashboard's forget) while its scheduler still runs it is declared again with its schedule, by the next declare or first thing in unschedule, so it is never taken for an entry gone, and a fallback forgotten is made again from the store (the Go port's d5d043b, the Rust port's 702bd56). A run whose start failed needs a placeholder wherever a scheduler reports a run's start and end as separate calls (the Go port's gocron finding, the Elixir port's telemetry stack): each integration here that is told of starts and ends separately keeps what its end needs on the scheduler's own object for the invocation (nothing when the attempt is not watched or its start failed), so the two always pair, however jobs nest; the starter's per-thread stack, for ShedLock, pushes a frame with no run for a method not watched. The app tag is the PHP port's appTag() character for character (MD5 when empty or long), and the app is the integration's app option, else $CRONWATCH_APP_ID, else spring.application.name in the starter, else the main class's simple name.
A run seen from outside the function (a listener told of its start and end) is opened and closed by the two halves of run (Runs.open and close, or takeBack), so the running row, the current run, the MDC keys, and the shutdown hook's record are run's, as the Elixir port's Exec.open/2 and close/2 are. The public face is job.open(RunOptions), which answers an ObservedRun (close(failure), closeWith(value), takeBack(), endFunction(), and the static clearEnded(), which clears the calling thread's current run and the MDC keys naming it once that run's function has ended: a run closed from another thread than the one that opened it cannot put them back there, so a listener whose attempts may end without a word calls it as each starts on a pooled thread, and opening a run does it too); RunOptions.withId gives the run the integration's id and RunOptions.mayTakeBack() (which withDiscardWhen implies) leaves missed and stuck open until the run is known not to be given back. Each integration pairs a start with its end through the scheduler's own object for the invocation (Spring's Observation.Context, Quartz's JobExecutionContext, JobRunr's job id) rather than a per-thread stack, which pairs them however jobs nest and wherever a start failed; the starter keeps a per-thread stack (RunFrames, a frame with no run for a method not watched) only so ShedLock's wrapped lock provider can find the invocation it is guarding (see Decisions, 48). The bridge reads what job() would declare without declaring it through an accessor of the module's own (internal.core.Access, see Decisions, 57), so none of it is public API on Cronwatch.
The starter watches every @Scheduled method without changes to the app's code: adding the dependency is enough, and cronwatch.scheduled.enabled=false turns it off. Spring Framework 6.1 instruments each @Scheduled invocation with a Micrometer Observation (tasks.scheduled.execution), started in the thread that runs the method, with a ScheduledTaskObservationContext naming the target class and the Method, marked with the method's throwable when it throws, and stopped when it returns; ScheduledTaskRegistrar.setObservationRegistry sets the registry it reports to, and the registrar is handed to every SchedulingConfigurer before any task is scheduled. The starter's SchedulingConfigurer gives the registrar the app's ObservationRegistry with CronWatch's ObservationHandler added (or, when the app has none or it is the no-op registry, a registry holding only that handler), and the handler opens a run on onStart and closes it on onStop, failed with the error the context carries. micrometer-observation comes with spring-context, so this adds nothing to the app. The two other ways were weighed (see Decisions, 12): a TaskDecorator on the scheduler sees each run as a Runnable wrapped in Spring's own internal classes, has to unwrap them to learn which method it is, and never sees the method's exception, which Spring's DelegatingErrorHandlingRunnable catches inside it; replacing the registrar's tasks with wrapped ones from a SchedulingConfigurer sees the exception but breaks the link between a bean and the tasks Spring cancels when the bean is destroyed. The observation is Spring's public, documented hook for exactly this, and needs Spring Framework 6.1, which Spring Boot 3.2 brought and every supported Boot has.
- Which jobs. Every
@Scheduled(and@Schedules) method of every bean, found by the starter's ownBeanPostProcessorreading the annotations as Spring's does (placeholders and expressions resolved through anEmbeddedValueResolver,cron = "-"disabled,zone,fixedRate,fixedDelay, and theirStringforms in a number, ISO-8601, or the simple5mform, andtimeUnit), only when the app has scheduling on (Spring's scheduled annotation processor is a bean), declared once every singleton is made. The draft's confirmation against the registrar's tasks was not needed: the post-processor sees the beans Spring's own does (see Decisions, 50). Each is a job namedSimpleClassName.method(NightlyReports.build), the class being the bean's user class, not its proxy's, and the fully qualified name when two beans' simple names collide (see Decisions, 13);@CronwatchJob(name = "nightly-report", grace = "15m", expect = "Report written", ...)on the method names it and gives its options, andcronwatch.jobs.<name>.*properties do the same from configuration. - Schedules. A
cronis Spring's six-field expression (seconds first) in the annotation'szone, else the JVM's; croner reads six fields the same way, so it is declared as written and checked withSchedulerBridge.checkFiresagainst Spring's own reading (CronExpression.parse(expr).next(...)in the zone): Spring requires a day of the month and a day of the week both to match where croner matches either, and reads?,L,W, and#in its own ways, so an expression the two read differently is reported once and watched without a schedule, as the Rust port does for tokio-cron-scheduler. AfixedRateis declaredevery <rate>; afixedDelayis declaredevery <delay>too, and since its runs start a delay after the last one ended, the README says to give such a job a grace as long as its longest run. A method with several schedules is one job without a schedule, as the bridge has it. - Each invocation a run (trigger
spring-scheduled, the integration's name as its tag is, since 0.11; runs recorded before it carryscheduled, and nothing reads the trigger back; idscheduled:<app>:<uuid>, a key that keeps its prefix).@Scheduledhas no retries, so every invocation is judged; a method that throws fails its run (a checked exception Spring's reflection wrapped in anUndeclaredThrowableExceptionis written as itself), and Spring's error handler then does what it did before.Cronwatch.current()andjob.logfrom inside the method work with no code, since the run is current in the method's thread. A@Scheduledmethod that returns aMono, aFlux, or a Kotlin flow, or a Kotlinsuspendfunction, is observed by Spring around its subscription, not its work; such a method is declared without its schedule, never has a run, and is reported once, until someone asks. Jobs are taggedspring-scheduledandspring-scheduled:<app>. An invocation is found by its target class and method; behind a JDK proxy (spring.aop.proxy-target-class=falseand an advised bean) the class is the proxy's, which every bean with its interfaces shares, and the method the interface's, so it is credited only when one scheduled bean fits, and when several do it is reported once and recorded for none, never all to whichever was found first. - The registry. The starter's
SchedulingConfigurer(ordered last) gives the registrar the registry an earlier configurer set, or the app'sObservationRegistry, when either has handlers of its own, with CronWatch's handler added; else a registry of the starter's own holding only that handler, so an app without observability gets no new observations anywhere else. The handler is added to the app's registry too, so Spring Boot's actuator setting it afterwards keeps the runs. Micrometer cannot take a handler off a registry, so when the context closes the handler stops opening runs (a run open then is still closed). - The check. The starter runs its own interval (
cronwatch.check-every, a minute by default, the first a second after the context starts) on a virtual thread, rather thancw.startChecking(), so each integration's sync (its jobs declared again, the declarations written, and jobs gone from the app declared again without their schedule, within 30 seconds) runs before each check.cronwatch.check-modesays how a cluster runs it:auto(ShedLock when the app has aLockProvider, else Quartz when its scheduler is clustered, elselocal),local,shedlock,quartz, ornone(see Decisions, 49).
ShedLock. Many Spring apps run the same @Scheduled methods on every instance and let ShedLock's @SchedulerLock choose one: the method is proxied, and on the instances that do not get the lock the proxy returns without running it. Spring's observation wraps the proxy, so each of those instances would record a run that did nothing, several a fire. When ShedLock is on the class path, the starter wraps the app's LockProvider bean, so in the method's thread it knows whether the lock was taken: a run whose lock was not taken is given back (deleteRunIf, nothing judged, the Go port's DiscardWhen rule), and only the instance that ran the method records it. The check, with ShedLock present, is a @Scheduled job of the starter's own under a lock of its own name, run once per minute across the cluster, in place of start() on every instance (see Decisions, 14).
The tests run a real Spring Boot application context (Boot 3.5 and the newest 4.x), with methods on schedules that fire each second, in the JVM's clock: a method recorded with its output logged through current(), a throw failing the run and Spring's error handler still called (a checked one too), a zone, a placeholder expression, an expression Spring reads differently reported, fixedRate and fixedDelay, @CronwatchJob's options and cronwatch.jobs[...]'s, a bean destroyed and its job declared again without its schedule (and nothing unscheduled when the context closes), an app with an ObservationRegistry of its own (its other handlers still called) and one without, the handler detached once the context closes, a reactive method reported, and ShedLock over a lock provider that grants in one of two contexts sharing one store, one recording and the other giving back (the test fails when the wrapper is broken). The client's properties, the store over the app's DataSource under Spring's transaction-aware proxy, cronwatch.enabled=false, an app's own client, and a Quartz scheduler bean watched are tested beside them.
cronwatch-quartz watches a Quartz 2.5 Scheduler (org.quartz-scheduler:quartz, 2.5.0 or newer, the first on Jakarta, Java 11): CronwatchQuartz.watch(cw, scheduler, QuartzOptions), or nothing at all in a Spring Boot app, where the starter watches every Scheduler bean once every singleton is made, before the scheduler starts (not through a SchedulerFactoryBeanCustomizer, whose package moved in Boot 4; see Decisions, 51). close() stops recording new firings at once and takes its listeners off the scheduler once the firings open then have ended, since Quartz tells only the listeners it holds when a job ends that it was executed; a run open at the close is still closed, not left to be reported stuck (the .NET port's rule). Quartz is the other scheduler Java apps run, clustered over a JDBC job store in many of them.
- Which jobs. Every
JobDetailthe scheduler holds with at least one trigger, named after itsJobKey(nightlyReportin theDEFAULTgroup,reports.nightlyin another), read when the integration starts and again when the scheduler says a job or trigger was added or removed (aSchedulerListener, whose calls only ask the integration's own thread to read again, since they come from inside Quartz's own locks), and every minute besides. A job with oneCronTriggeris declared on its expression in the trigger's zone, a?written*(croner reads a?as a day field that names every day, so0 0 2 1 * ?would fire daily; phase 5's audit found it, and the starter declares a@Scheduledcron's?the same way); Quartz's expressions have seconds,?in one day field, days of the week counted from 1 for Sunday and an optional year, so each is checked withBridge.checkFiresagainst Quartz's ownCronExpression.getNextValidTimeAfterand one that differs (nearly every expression naming a day of the week by number) is reported once and watched without a schedule. ASimpleTriggerrepeating forever is declaredevery <interval>; aCalendarIntervalTrigger, aDailyTimeIntervalTrigger, a trigger with aCalendarexcluding times, or several triggers on one job is a job without a schedule, as the bridge has it. - Each firing a run. A global
JobListener:jobToBeExecutedandjobWasExecutedare called by Quartz'sJobRunShellin the worker thread, aroundexecute, so the first opens a run (triggerquartz, idquartz:<app>:<scheduler instance id>:<fire instance id>:<refire count>, since a fire instance id is unique only within one scheduler instance and a refire reuses it) and makes it current, and the second closes it, failed with theJobExecutionExceptionQuartz hands it (an exceptionexecutethrew is wrapped in one byJobRunShell, and its cause is what is written). AJobExecutionExceptionwithrefireImmediatelyis a failed attempt, and its refire is a new run, as a retry is under the Sidekiq rules. A vetoed firing (jobExecutionVetoed) opens nothing. A firing whose job is@DisallowConcurrentExecutionand blocked is never executed and opens nothing. - Recovery. In a clustered store, a job a node was running when it died is fired again on another node with
isRecovering()true: that firing's start finishes the earlier firing's run, if it is still running, as failed withQuartz recovered the job after its node stopped, as the Elixir port's Oban Lifeline rescue does. The earlier run is found among the job's recent runs: one still running, of this app (its id'squartz:<app>:), from the scheduler instance that died, started within a minute of the original firing's time, which Quartz puts in the recovering trigger's data (QRTZ_FAILED_JOB_ORIG_TRIGGER_FIRETIME_IN_MILLISECONDS_AS_STRING). Quartz does not hand over the dead instance in that data, but it names the recovering trigger after it (recover_<instance id>_<n>in theRECOVERING_JOBSgroup, both where a node recovers another's firings and where an instance recovers its own as it starts again), so a run of a third node still alive, started in the same minute, is left to finish. A trigger named otherwise falls back to any other instance's run in the minute. A misfire is Quartz's own business; the missed check reports a fire that never ran whatever the misfire policy did. - Expressions. A seventh, year field of
*or?is left out of what is declared; one naming years is kept for the check to judge. A trigger's zone is itsTimeZone's id. A job whose Quartz name is not a CronWatch name is reported and not watched. A run id past 200 characters keeps itsquartz:<app>:prefix and a hash of the rest. - The check.
CronwatchCheckJob, a Quartz job the app schedules once (CronwatchQuartz.scheduleCheck(scheduler), every minute,@DisallowConcurrentExecution), runssync(the jobs declared again,settle, thenunschedule, within 30 seconds) and a check once per minute across a cluster; its runs are never a job. On a node with no live watch of the scheduler it throws aJobExecutionExceptionsaying so, so Quartz records a failed check, not one that did nothing.
The tests start a real Quartz scheduler on the RAM job store always, and on the JDBC job store over Postgres when CRONWATCH_TEST_PG is set, with triggers that fire each second: jobs declared (a zone, a year field, a day of the week Quartz reads differently reported, a simple trigger, a job with two triggers), a job failing twice through refires and then succeeding (three runs, one alert and its recovery), a throw, a veto, a job deleted at run time declared again without its schedule, the check job unscheduling a job the scheduler dropped, and a recovery on the JDBC store after a scheduler is shut down without waiting for its jobs.
JobRunr (org.jobrunr:jobrunr, 8.x) is the third scheduler Spring Boot apps commonly run, with recurring jobs on a cron in its free edition, retries (ten by default), and a dashboard of its own. cronwatch-jobrunr is kept (see Decisions, 16): JobRunr's BackgroundJobPerformer calls a JobServerFilter's onProcessing in the worker thread just before the job and onProcessingSucceeded, or onProcessingFailed with the exception, there just after (its bytecode shows it and the tests hold it, on 8.0.0 and the newest). CronwatchJobRunr.watch(cw, storageProvider, JobRunrOptions) is the filter the app gives the background job server (before the server, as JobRunr 8.0 asks). Recurring jobs are declared from StorageProvider.getRecurringJobs() by their id, a cron in its zone checked against JobRunr's own Schedule.next, an interval every <interval>, a carbon-aware schedule without one; they are read when it starts and every minute. Each attempt is a run (trigger jobrunr, id jobrunr:<app>:<job id>:<attempt>, the attempt counted from the job's processing states), so a retry is a run of its own; a job that is not recurring is watched when its JobRunr name (@Job(name = ...)) is given to watchJob. The check is a recurring job of its own (cronwatch-check, every minute, never retried), which scheduleCheck(jobScheduler) makes; it syncs every watch live in the JVM (a set, so a second watch adds to the first rather than replacing it, and close takes one out), checks each client once, and on a server with no live watch throws an IllegalStateException saying so, so JobRunr records a failed check. The filter is also an ApplyStateFilter: a state JobRunr applies other than processing (failed, deleted, scheduled or enqueued again, an orphan its own server moved on) closes a run still open for the job, which the processing filters close first in the usual course; and the check closes a run whose row it has marked timed out once JobRunr no longer has that attempt processing, so the open map never keeps an attempt JobRunr will not report. An attempt that ends without a word (an Error) left its run current, with its MDC keys, on the pooled worker's thread; onProcessing, which JobRunr calls on the worker for every job it starts, watched or not, first closes a run of ours still current there (the worker runs one job at a time, so that attempt has ended), in the thread that opened it, which puts both back, and clears one already closed from another thread (ObservedRun.clearEnded()). The tests run a real background job server on JobRunr's in-memory storage: the recurring jobs declared, a deleted one unscheduled, a named job's run with its output, a retry as a second run, a recurring job's run, and the check. The starter does not register the filter: JobRunr's own Spring Boot starter decides how filters reach its server.
A program a crontab runs needs no integration: build the client in main, run the job, System.exit(1) on failure, and a second crontab line runs a check, both on a store both processes reach (SQLite on the box, or the app's database). The check line needs the same store and channels as the job, which only the app knows, so the command-line entry is a class the app calls from a main of its own rather than a tool of the library's: CronwatchCli.main(MyApp::cronwatch, args) takes the app's own factory for its client and runs check (one check, the result printed as cronwatch: checked 3 jobs, sent 1 alert, exit status non-zero on an error, the client closed), so a crontab line is java -cp app.jar com.example.CronwatchMain check. A wrapper that runs a shell command as a job (... run nightly -- ./backup.sh) was weighed and left out, as every other port left out its own, until someone asks (see Decisions, 19). CronwatchCli.main ends the JVM with the status only when the check fails, for a main of the app's own; CronwatchCli.run answers the status (0, 1 for a failed check, 2 for a command it does not know) and never ends the JVM, for code already running in an app, the Elixir audit's rule that a check entry point never halts the running app. examples/crontab is that program on SQLite (Nightly and CronwatchMain, a module of the build that is never deployed), with a test that runs its two lines as two JVMs on one file.
conformance/ at the repo root holds JSON cases generated from the TypeScript build by scripts/conformance.mjs. The Java tests replay every case, comparing values as the JSON the SDK writes, byte for byte, reading the files from the repository (their directory given to Surefire as a system property from ${maven.multiModuleProjectDirectory}; no jar carries them): duration.json (103 cases at 0.8.0) and schedule.json (723) against the duration and schedule code, output.json (231) against the output code, evaluate.json (50 scenarios, every event played through the pure functions, as the script's Sim plays them), format.json (104), and health.json (145) in the core, store.json (38) against every store, channels.json (689 cases in every section: sends, failures, providerSends, providerFailures, twilioPartial, textCuts) against the channels, triage.json (20: requests, responses, and the wire cases) against triage and pgcron.json (30) against the source. A test fails when the SDK writes a fixture file this port does not know. The tests run with TZ=UTC and -Duser.timezone=UTC, as the fixtures are made; Surefire sets both and a test checks them. A behaviour change lands in TypeScript first, npm run conformance regenerates the fixtures, and this build is fixed until they pass. Nothing new is expected in the fixtures for this port: the PHP and Go ports already added what a port without the official clients needs (triage.json's wire cases).
The foreign-row fixes every port took at 0.8.0 are part of phase 1, not findings for its audit: runDuration held from 0 to 2^53 - 1; stateVersion counting a version that is not a whole number as 0, in the client, the memory store, and the SQL of every dialect; a time before the year 1 or after 9999 written in words wherever the SDK would write a date (before 0001-01-01 00:00:00 UTC, after 9999-12-31 23:59:59 UTC, on the timeline before 1 Jan 0001 00:00 and after 31 Dec 9999 23:59, a date in another year with its year), with no relative part, and a tracker's startedAt as null, since java.time would print such a date where toISOString throws; and a cron that counts from a start before the year 1 as from the year's first millisecond, finds no fire after 9999, and asks croner's walk only within the years it answers for, moving a time outside 400 to 2800 by whole 400-year cycles of 146,097 days (a whole number of weeks, so dates and weekdays repeat) and moving the fires back, as schedule.ts's runsAfter does. health.json, store.json's foreignVersion, format.json, channels.json, triage.json, schedule.json, evaluate.json, and the golden seed hold every case.
Croner parity is also checked against croner itself, as the other ports do: 3,000 expressions from three fixed seeds in zones with and without daylight saving, from times around the clock changes, where the SDK in Node (reading packages/sdk/dist) must give the same error message or the same fire times; it skips, with the reason, when node or the built SDK is missing. The same generator run against Spring's CronExpression, Quartz's CronExpression, and cron-utils is a report, not a gate, so the reason for porting rather than depending stays measured, as the Rust port's report on the croner crate does: ReadingsReportTest in the starter's tests (cron-utils 9.2.1 a test dependency there), skipped unless -Dcronwatch.readings=true, checks 40 generated expressions with a seconds field (seed 20260929, the Elixir report's grammar, in UTC, America/New_York, Europe/London, and Australia/Lord_Howe) with Bridge.checkFires, as the integrations check a declared job. At phase 5: Spring reads 15 as croner does (22 name a day of the month and a day of the week both, which Spring requires to match together and croner either; 2 step the day of the week, which Spring counts from Monday as 1 and croner from Sunday as 0; 1 is the clock change), Quartz 11 (given ? where the other day field is *, as the integration declares it: 23 refused, 22 of them naming both day fields and 1 a day of the week 0; 5 name a day of the week by number, which Quartz counts from 1 for Sunday; 1 the clock change), cron-utils with its Spring 5.3 definition 17 (22 both day fields, 1 the clock change) and with its Unix definition, five fields, 39. The one every library reads otherwise is 0 */4 * * 10 * in Australia/Lord_Howe, whose clocks go forward half an hour at 02:00: croner fires at 02:30 and each library skips it. Quartz's getNextValidTimeAfter also skips the repeated hour at the autumn change for a cron within the hour (*/10 * * * * ?), so such a trigger in a zone with daylight saving is watched without a schedule, as the check says.
Why port croner rather than use a Java cron library. Three were weighed. Spring's CronExpression is what @Scheduled uses, but it lives in spring-context (a dependency no plain app should take for a parser), requires six fields, matches a day of the month and a day of the week both, and has its own messages. Quartz's CronExpression requires seconds and a ?, counts the days of the week from Sunday as 1 and has a year field. cron-utils can be configured toward several dialects, but none is croner 10, its messages are its own, and its fire times around clock changes are its own. CronWatch needs the SDK's messages and fire times byte for byte, which five ports already get from a line-for-line translation checked against croner itself; the Rust port's schedule::cron is the nearest code to start from, and Java's long and Math.floorDiv make it nearly a transliteration.
./mvnw verify in packages/java is the whole suite; the Postgres, MySQL, MariaDB, and pg_cron tests run when their CRONWATCH_TEST_* variables are set, as the other ports' do. The tests are JUnit Jupiter on JUnit 6 (which keeps the JUnit 5 API and needs Java 17, below the floor), run on the class path as most apps run the library, with ModuleTest holding the module's descriptor and resolving it with none of its optional libraries. The client, store, handle, finish-once, and routes tests are the SDK's, ported, as the Go, Rust, and Elixir ports ported them; the SDK's fake clocks are a clock the client takes in its builder (clock(LongSupplier)), which the tests drive, and where a timer must fire (a job's timeout, the interval, a channel's deadline) the tests use short real ones, the deadlines being package-private settings the tests shorten. Every README example is compiled by a test (the README's Java blocks extracted into a source set), so the suite fails when one stops compiling, as the Rust doc tests and the Elixir README test do.
The fuzz targets of the Rust port are property tests here, seeded JUnit tests over a small generator of the tests' own (Gen), one per door untrusted input comes through (jqwik was the draft's choice; see Decisions, 24): Js.parse and stringify round trips, duration text through the public options, a cron expression in nine zones and its fire times each later than the last, a stored pattern run by the engine as a test and as a replacement within its budget, a request to the dashboard in five configurations (the routes catch a throw and answer 500, which the property counts as a failure), and stored rows read, written, and read back. They run a few hundred tries each in verify, and -Dcronwatch.tries=N multiplies them for a longer run, which fuzz.yml is to make once a week beside the Rust targets.
Static analysis is Error Prone (see Decisions, 21), a javac plugin that fails the compile on the bug patterns it finds (a String compared with ==, a lock not released in finally, a Future ignored, an interrupt swallowed, a toString on an array), with its warnings as errors and the checks the ported code trips on switched off by name; the formatting is google-java-format through Spotless, checked in CI. SpotBugs was weighed: it reads bytecode after the build, finds some things Error Prone does not (a mutable static exposed), and is slower and noisier; Error Prone is the counterpart of clippy and credo --strict that the other ports run. javac -Xlint:all -Werror runs too, javadoc with doclint on every public type (Maven Central needs the javadoc jar anyway), and the Maven Enforcer plugin holds the core to no dependencies, every module to Java 21 bytecode (maven.compiler.release 21), and the build to one version of each dependency.
Phase 1 has the first of these jobs' core: java on Temurin 21 and 27, through actions/setup-java pinned by commit, with ~/.m2/repository and the wrapper's Maven cached by the JDK and the POMs, after npm run check:conformance builds the SDK; ./mvnw -B verify on both (Error Prone, -Xlint:all, the Enforcer rules and every test, the croner parity check and the SQLite file shared with Node included), and on the newest spotless:check and a check of the core jar's files against the tests' classes. The rest of this list comes with the phases that need it:
The CI jobs, beside elixir, each after npm run check:conformance builds the SDK for the croner parity and Node compatibility tests, with actions/setup-java (Temurin) pinned by commit and ~/.m2 cached by the JDK and the POMs:
java, on JDK 21, 25, and 27 (ubuntu):./mvnw -B verifywith the databases (postgres:16-alpine, mysql:8.4 and mariadb:10.11 as services, withCRONWATCH_TEST_PG,CRONWATCH_TEST_MYSQL, andCRONWATCH_TEST_MARIADBset as URLs,postgres://postgres:pw@127.0.0.1:5432/cw,mysql://root:pw@127.0.0.1:3306/cwand MariaDB on 3307; on the newest entry a Postgres with pg_cron started as the elixir job starts it,CRONWATCH_TEST_PGCRON; phase 2 added them). The tests'Serversturns each URL into a JDBC one behind a smallDriverManagerdata source, so no driver class is named, and each test uses tables of a prefix of its own (the pg_cron SQL test a database of its own), dropped at the end;CRONWATCH_TEST_JAVA=1 npm test --workspace packages/mcpagainstwebserver; and on the newest entry Spotless, the javadoc build, and a check of each published jar's files (its classes,module-info, resources, andMETA-INF, nothing fromtestorconformance), asrelease.mjschecks the gem's list.java-frameworks, JDK 21: the starter's and the Quartz module's tests against the oldest and newest release each supports (Spring Boot 3.5.0 and the newest 4.x, Quartz 2.5.0 and the newest), the starter's web tests included (the WebFlux replay under Netty on both Boot lines), chosen with-Dspring-boot.version=,-Dshedlock.version=,-Dquartz.version=, and-Djobrunr.version=, since Maven resolves exactly the version a POM names and an app's own build decides which one it runs. The starter's POM pins a patched Tomcat 11 for the tests only in itsboot4-tomcatprofile, active when no-Dspring-boot.version=is given (the default build, on Boot 4), so a run that picks a release (the oldest, Boot 3.5 on its own Tomcat 10.1) tests that release's Tomcat from its BOM.java-platforms, JDK 27 on macOS and Windows:./mvnw -B verifywith no database services, so the server tests skip as they say, and without the built SDK, so the parity check and the SQLite file shared with Node skip too. It covers the JDK's zones, paths, and line endings: Windows checks out withcore.autocrlfoff, since the assets and fixtures are compared byte for byte.
CRONWATCH_TEST_JAVA=1 npm test --workspace packages/mcp drives @cronwatch/mcp against webserver (a module of the build that depends on the core, seeded, served by the JDK's HttpServer with the dashboard at /cronwatch), built with ./mvnw -q -pl webserver -am package and started with java -jar, with CRONWATCH_MVN for another Maven and CRONWATCH_JAVA for another JDK, in a new packages/mcp/test/java-web.test.ts beside the other ports'. npm run check stays the gate for the repository; the dash check already covers packages/java, .gitignore gains packages/java/**/target, and CLAUDE.md names the port and how it is tested, as it does each of the others, in phase 1.
The first release is published by hand, once phase 1 works, at the next batched release: the owner verifies dev.cronwatch on the Portal (the DNS TXT record), makes a signing key, and runs ./mvnw -P release deploy in packages/java with the key in a local agent and a Portal user token in ~/.m2/settings.xml; the release profile builds the sources and javadoc jars, signs every file with maven-gpg-plugin, and uploads the bundle with central-publishing-maven-plugin set not to publish on its own, so the owner checks the validated deployment in the Portal and presses Publish. webserver and examples are left out of the bundle.
Publishing after that is .github/workflows/java.yml, on a pushed v* tag or by hand for a tag, off until the repository variable MAVEN_ENABLED is true, in an environment maven (with required reviewers, since a Central release cannot be taken back), as pypi.yml and crates.yml are: after ci-passed.yml has waited for the tag's CI on main to pass, a job with no secret checks that <revision> is the tag's, runs ./mvnw -B -P release -Dgpg.skip verify (the tests, the jars, the sources and javadoc jars and the flattened POMs) and lays the five published modules out as the Portal's bundle with their checksums; a second job, the only one in the maven environment, runs no build and resolves nothing: it imports the signing key, signs each file with gpg, zips the bundle, uploads it to the Portal's publisher API with curl (publishing once validation passes) and waits until it is published. The wrapper checks Maven's distribution against distributionSha256Sum.
Maven Central has no trusted publishing: the Central Publisher Portal authenticates with user tokens only, and nothing in its documentation mentions OIDC or GitHub as a publisher (checked 2026-09-29; the Portal validates Sigstore bundles since 2025, but they do not replace the PGP signature or the token). So the workflow takes, from the maven environment's secrets and never the repository's: CENTRAL_TOKEN_USERNAME and CENTRAL_TOKEN_PASSWORD, a Portal user token the owner generates for this alone and can revoke; MAVEN_GPG_PRIVATE_KEY, an armoured signing subkey only, exported with gpg --export-secret-subkeys so the primary key never leaves the owner's machine, set to expire in two years; and MAVEN_GPG_PASSPHRASE. The key's public half is published to keys.openpgp.org and keyserver.ubuntu.com, which Central checks signatures against. A leaked subkey is revoked from the primary key and replaced without changing the identity apps may pin; a leaked token is revoked in the Portal. When Central ships trusted publishing, the workflow moves to it and the token is revoked; the signature stays until Central stops requiring it (see Decisions, 20).
- Done: the Maven build (the parent POM with
<revision>, the flatten plugin, the wrapper at Maven 3.9.16, the Enforcer's rules with no compile or runtime dependency in the core, Error Prone 2.50 and-Xlint:allwith warnings as errors, Spotless with google-java-format) and thedev.cronwatchmodule; the core: durations with the 64 character cap, schedules on the croner port with the far-start rules (countFrom,runsAfter's 400-year shift), zones throughjava.time, evaluation as pure functions, stats, the alert text withtoLocaleString's numbers, stored definitions and expect rules,JsonandJsObject, the output cap andjsreredaction with the Rust port's bounds (a stored pattern that runs out fails), the client with runs in the caller (runandcall, see Decisions, 22), handles,recordRun,checkwith one shared checker,startandstop,silence,forget, sources, deferred delivery and the triage hook, the shutdown hook,interruptAtTimeout,current()withwrap, Micrometer's context propagation, and the MDC keys;MemoryStore;SqlStoreon SQLite over aDataSource(xerial's driver, a test dependency) with the schema byte for byte and the Postgres statements written;storetest(StoreContract,StoreReplayofstore.jsonwith itsforeignVersioncases,FinishOnce, andForeignRows). Every case ofduration.json(parse 68, format 26, relative 9),schedule.json(parse 62, fires 64, nextFire 4, expectation 572, runCovers 13, autumn 8),output.json(the cap, 204 redactions, 16 error messages, 11 recorder cases),evaluate.json(50 scenarios),format.json(32 alerts, 38 numbers, 14 caps, 7 definitions, 13 expect checks),health.json(145), andstore.json(26 cases and 16 foreign versions, on the memory store and SQLite) is replayed byte for byte, and the harness fails on a fixture it does not know;channels.json,triage.json, andpgcron.jsonare named for phase 2. The croner parity check found no differences over 3,000 expressions (584, 603, and 593 valid from the three seeds, the rest refused with croner's message). The SQLite file shared with Node holds: each reads the other's rows and every column's bytes and type the same,sqlite_masteris the same whoever makes the tables, a Node client and a Java client take turns on one file, and on one job's state version. The SDK's client, start and finish, concurrency, correctness, and client hardening tests are ported (the handler's cases wait for phase 3, the Postgres ones for phase 2), with the Rust audit's core-client cases and the JVM's own: a run whose thread is interrupted, a caller interrupted while its run is recorded,interruptAtTimeout,current()acrosswrap, a propagating executor and a pooled thread, 200 runs at once on virtual threads, checks shared and a caller interrupted while it waits, a JVM stopping with a run open in a child JVM, a run afterclose(). The properties are seeded JUnit tests (see Decisions, 24);ModuleTestresolves the module with none of its optional libraries; the README's examples compile as a test. 197 tests, none skipped with the SDK built, on JDK 21 (21.0.12) and JDK 27, and none of the new tests found a bug in the client. Its release rows inscripts/release.mjs,CLAUDE.md's line,.gitignore, and thejavaCI job came with it. The first publish by hand follows, at the next batched release, oncedev.cronwatchis verified. - Done:
SqlStoreon Postgres, MySQL, and MariaDB. Each server runs the contract,store.json(32 cases and 16 foreign versions), finish-once over several clients, a client end to end, the rollback test, and rows of another shape (a run started at the lowestBIGINT, the far starts); each dialect has its own tests as the Elixir port has them (Postgres's schema, JSONB numbers and key order, ties and byte order, NUL, a compare-and-set race, eight inits at once; MySQL's and MariaDB's bytes, conditional writes counted both ways, long triggers), and the untyped JSONB bind is confirmed. The pg_cron source replays all 30 cases ofpgcron.json, the SDK's source tests over an in-memory fake, its SQL against a fakecronschema in Postgres, and the three real pg_cron tests. The fifteen alert channels overPostandJdkTransport: all 689 cases ofchannels.jsonand the hardening suite against real local servers, every answer about the JDK's client confirmed (see Channels), so no fallback was needed; SigV4 against the AWS test suite. Claude triage: all 20 cases oftriage.json, and a request to a real local server. ThejavaCI job runs postgres:16-alpine, mysql:8.4 and mariadb:10.11 as services, and on the newest entry a Postgres with pg_cron. Every file inconformance/is replayed, and the harness fails on one it does not know. 300 tests, none skipped with every server set and the SDK built, on JDK 21 (21.0.12) and JDK 27. - Done: the dashboard and JSON API, framework-free (
dev.cronwatch.web:Routes,Handler,Request,Response,Adapters, with the pages and timelines ininternal/web, the Rust port'sweb/carried over), with the JDK server adapter in the core (WebServer),cronwatch-servlet(CronwatchFilter,CronwatchServlet, Jakarta Servlet 6.0), and the starter's MVC and WebFlux registration (dev.cronwatch.spring.web); all 63 captures ofgolden.jsonmatched byte for byte straight into the routes, and through the JDK's server, Jetty 12, Spring MVC on Tomcat, and WebFlux on Netty (61 each, the two targets that are not URIs refused by every server itself; see Dashboard), withgolden.mjsandmake-dashboard-icons.mjswriting and checking the Java copies of the assets; the app shell, the token and cookie sign-in, the development token and its loopback-only line (the origin read as a URL),trustProxy, the cross-site check, silence (the 64 character cap included), forget and check, the 1 MiB body cap, long names wrapping; the handler; the SDK's routes and handler tests, ported, with the Go, Rust, and Elixir audits' cases (aHostoutside ASCII over 1024 bytes, a body cut short read as none, over the wire too, a request that ended not reported, a body nested deeply, huge durations, a foreign row's far times, a form a filter ahead already read), the environment's cases in child JVMs, the secrets kept out of every request and options type'stoString, and a property that any request, in five configurations, is answered without a 500;webserverand the MCP end-to-end test, in CI on both entries, with the jar check covering the servlet and starter jars. 54 tests and properties in the core's web tests, 5 inwebtest, 6 incronwatch-servlet, and 5 in the starter, on JDK 21 (21.0.12) and 27; none of the audits' cases found a bug in the port as first written. The starter's client bean and properties are phase 4's, built beside this (see Decisions, 46). - Done:
dev.cronwatch.bridge(Bridge,SchedulerBridgesince 0.11 to match the .NET port, withBridgekept as a deprecated alias, and the package outside the 1.x promise: the app tag through MD5, character for character the PHP port's, with its vectors;checkFiresover a scheduler'sFireTimes,FireTimes.walkingfor one that can only be asked for its next fire;unscheduled,optionsOf,syncWithin;Watchwith every audit's fix:unschedulereads what is declared again under the declaring lock and writes every declaration before it returns, a declaration's write held to 30 seconds and a sync an integration starts to 30 more,fallbackdeclaring nothing on a failed store read, a throw in one write never leaving the writer busy), runs opened from outside and given back (job.open,ObservedRun,RunOptions.withId,withDiscardWhen, andmayTakeBack,RunHandle.fail(String)), the Spring Boot starter (the client fromcronwatch.*,@Scheduledthrough Spring's observation,@CronwatchJob, ShedLock, the Quartz schedulers, the check and its modes),cronwatch-quartz(the globalJobListener, the recovery rule,CronwatchCheckJob),cronwatch-jobrunr(kept),CronwatchCli, andexamples/crontab, and thejava-frameworksCI entry, on Spring Boot 3.5.0 (ShedLock 6.10.0) and 4.1.1 (ShedLock 7.10.1), Quartz 2.5.0 and 2.5.2, JobRunr 8.0.0 and 8.8.2. Integration run ids carry the app (scheduled:<app>:,quartz:<app>:,jobrunr:<app>:), so two apps sharing a store never collide; each listener or handler is taken off, or stops opening runs, when its integration or context stops. The new tests are the bridge's 20, 9 of runs opened from outside, 4 of the command line, the Quartz module's 10 (its recovery on Postgres withCRONWATCH_TEST_PG), the JobRunr module 3, the starter 16 (its README test compiles the integrations' examples), and the example 1. None of the new tests found a bug in the phase 1 client. Left for later, and made in phase 5: the report of Spring's, Quartz's, and cron-utils' readings against croner's (see Keeping in step). - Done: the audit before the Java docs go up, in four passes (the core client and its threads; the dashboard, handler, and adapters; the stores, pg_cron, channels, triage, and redaction; the integrations and the bridge) and a soundness and API pass, with the Go, Rust, and Elixir audits as the list of bug classes to look for, and the classes the JVM adds: a
ThreadLocalor MDC key left on a pooled thread, an interrupt swallowed or lost, a virtual thread pinned, asynchronizedaround I/O, a thread or shutdown hook that holds an undeployed app's class loader, aCompletableFuturenever completed, anErrorcaught and not thrown again, a record that prints a secret, and a Spring bean that starts work before the context is refreshed. Each finding was shown by a failing test before it was fixed, and each fix has that test (AuditCoreTestand the suites beside them) unless said otherwise:- Medium. A client closed while the JVM stops (a Spring context's own shutdown hook calls
close()and then closes the pool, while the client's hook runs beside it) let its store go before the hook wrote, so a run open then stayedrunning, the case the hook is for:close()during the JVM's shutdown records the open runs first, and each is written once whichever path gets there (ShutdownHookTest, in a child JVM). AnErrorthrown by the app'sredactorexpectThat(aStackOverflowError) left the runrunningand lost how the function ended, and a handle'sfinish()was left finished with nothing recorded: the run is recorded failed with that throw, through the default redaction, and theErrorthrown again. The JDK'sHttpClientrefuses a header value with a control character or a character past U+00FF and quotes the value in its error, which reached the error handler, so a pasted credential with such a character was logged:JdkTransportnames only the header, andPostcuts every header value of four characters or more (butaccept,content-type, anduser-agent) out of any transport's error. A dashboard withcronwatch.web.open=true(no token, meant to sit behind the app's own sign-in) still ran at order -110, ahead of Spring Security's chain, so it was open to anyone: it runs at -90, behind the chain, unlesscronwatch.web.orderis set (see Decisions, 17). croner reads a?as a day field naming every day, so a Quartz or@Scheduledcron with a?(0 0 2 1 * ?,0 0 9 ? * MON, nearly every Quartz cron naming a day) was refused by the check against the scheduler's reading and watched without a schedule, its missed runs never reported: a?is declared*. A reactive@Scheduledmethod is observed starting in the scheduler's thread and stopping where its publisher completes, so its ShedLock frame was never taken off the first thread and one leaked per invocation on a pooled thread: a frame is taken off its own thread's stack whichever thread ends it (RunFramesTest). Withcronwatch.store=auto, a database merely down when the app started made it use the memory store for its whole life with only a warning: only the store's refusal of a database it does not know falls back, and any other failure fails the start. - Low. A job name that ever took the state lock kept it for good (a dashboard silence of any name, pg_cron's jobs coming and going): a job's lock is let go of once no one holds or waits for it, as the SDK drops an idle job's queue. Each
metric()copied every metric so far, so 60,000 names took some 30 seconds: they are set in place. AnexpectThatexception whosegetMessage()threw left the runrunning: it fails with an empty message. A servlet body a filter ahead had read throughgetReader()madegetInputStream()throw and the routes answer 500: it is read as none, as a body already read is (ServletTest). A transport body that answers empty chunks without blocking kept its virtual thread spinning after the deadline: the read stops when its caller gives up, and the answer is closed. Quartz's default instance id isNON_CLUSTEREDin every process that is not clustered and the RAM store counts fire ids from its start, so two processes of one app started together could give two firings one run id, and the second went unrecorded: a watch that is not clustered adds a random part to the instance id (a long one is hashed), which the recovery rule reads too. A job listener after ours that throws injobToBeExecutedstops Quartz running the job with nojobWasExecutedafter it, leaving the runrunning: it is given back.cronwatch.check-everyof zero or less in Quartz mode scheduled no check, and one past a long of nanoseconds stopped the app starting or ended the check's thread: the interval is held from five seconds to 2^31 - 1 ms wherever the check runs. Every read of the scheduler's jobs (each minute and each sync) walked every cron's fire times beside the scheduler's own again, up to a second and a half a cron in a zone with daylight saving: the answer is kept per job, expression, zone, and year, and only what the last read saw is kept.CronwatchCli.runlet a checked exception thrown anyway (Kotlin,@SneakyThrows) out of a factory escape, where it promises a status: it answers 1. - Left as they are. An
ObservedRunwithinterruptAtTimeoutclosed on another thread than the one that opened it can leave the timeout's interrupt on the opening thread; the javadoc says to close on the same thread, and every integration does. AnErrorfrom the app'sredactduringflush()drops the lines that flush took. AnErrorfrom a channel or triage is reported and the alert queued, not thrown again, since it is thrown on the client's own thread and the SDK counts any throw as that channel's failure. While the store hangs, each interval tick leaves one more virtual thread waiting on the shared check, and each timed-out sync of an integration one more waiting onunschedule's orfallback's read, as a hung promise holds its callbacks in the SDK. The app'sclockthrowing while a run is recorded leaves the runrunning(a clock is a hook for tests). Afterclose()the client's work runs in the caller, so a channel that hangs there has no deadline. A JobRunr job that throws anErrorgets no processing-failed call from JobRunr 8, so its run is reported stuck at its timeout, as a killed process's is. On MySQL and MariaDButf8mb4_binpads trailing spaces, so two run ids differing only in trailing spaces are one (only run ids an app gives itself); the schema is every port's and MariaDB 10.6 has no binary collation without padding, so it is a decision for every port at once.new ChannelContext(report)and the two-argumentnew Triage.Context(alert, runs), for code outside a client, each make a transport that is never closed; itsHttpClientthread ends once the client is collected. The JDK server's contexts match by prefix, so/cronwatchXreaches the routes and is answered 404 or 401. - Soundness and API.
alerts.Requestandalerts.ResponsebecameTransport.RequestandTransport.Response(Decisions, 56), andWebAccessandFriendsone accessor,Access(57). Every published jar carriesMETA-INF/LICENSE, which CI checks beside its files. The javadoc runs doclint's checks butmissingwith warnings as errors, documents only the exported packages, and is built on the newest CI entry, which this file said and CI had not done. Every public record and options type was checked for what itstoStringprints, and every exception message for what it can quote; the module's exports, what is public in them (the channel builders' shared bases have package-private constructors), and Error Prone's settings were read again, and none needed a change beyond these../mvnw -Dmaven.test.skip=true verify, and a module built alone with-pl ... -am, build, sincewebtestis a module of the reactor rather than a tests jar. - Fixed in TypeScript first, then here. On Postgres a NUL in a metric's name, a run id, or a trigger made the whole run's write fail, so the run was lost and later reported stuck, where only the output and the error had their NULs taken out: every store, in every dialect and in memory, now writes a run's trigger, output, error, and metric names, and every key and string of a definition and a state, without U+0000 (
Output.stripJsonNuldrops each\u0000escape from the JSON text), and a run id with a NUL is refused bystart,resume,run, andrecordRun;store.json'snulsteps replay it. In the pg_cron source an app'spick,jobName, oroptionsthat throws, or ajobNamethat answers null, failed the whole sync for every job: it fails only its job, reported once until it works again, and the job keeps its last declaration, so its runs are still copied. - Advisories (2026-09-29, the resolved dependencies of every module queried against OSV, and every pinned version checked against Maven Central, each the newest release): none in anything a published artifact brings into an app, the core bringing nothing. The newest resolution has, in test scope only, Tomcat 11.0.24's three (fixed in 11.0.25, which no Spring Boot has taken yet) and Jackson 3.1.5's three (fixed in 3.1.6), both through Spring Boot 4.1.1's dependencies for the starter's tests. The oldest resolution
java-frameworkstests, Spring Boot 3.5.0, has the advisories fixed since in Spring Framework 6.2.7 (6.2.8 to 6.2.19), Spring Boot 3.5.0 (3.5.14 and 3.5.15), and, in test scope, Tomcat 10.1.41, Netty 4.1.121, Reactor Netty 1.2.6, Jackson 2.19.0, Logback 1.5.18, and Log4j's API 2.24.3; the starter's Spring dependencies are optional, so the app's own Boot is the one that runs, and 3.5.0 stays the floor tested, as the Elixir port kept Plug 1.16.0. OWASP dependency-check was not run: it needs the NVD's database, which OSV's covers for Maven. - Done to close the phase. The WebFlux golden replay failed on Spring Boot 3.5.0: with Tomcat and Netty both on the tests' class path, Boot 3.5 served the reactive app on Tomcat (Boot 4 puts each server in a module of its own), which wrote the content type without the space and framed the answer as chunked; the test names Netty's server factory, so it runs on Netty on both Boot lines, and the reactive filter now sets the answer's
content-lengthwhenever it holds the whole body, so no server frames it as chunked. The starter's web tests are back injava-frameworks. The report of Spring's, Quartz's, and cron-utils' readings (see Keeping in step). The CI entries this file promised and phase 1 left for later:javaon JDK 25,java-platformson macOS and Windows, the javadoc, and the properties once a week at a thousand times their tries infuzz.yml(clean locally at that count), with a new property over foreign rows written raw into SQLite's three tables, read, checked, and written back (RowsProperties, 9,000 cases clean). 457 tests with every server set and the SDK built, on JDK 21 (21.0.12) and JDK 27, none skipped but the readings report: 398 in the core, 5 inwebtest, 7 incronwatch-servlet, 14 incronwatch-quartz, 3 incronwatch-jobrunr, 29 in the starter, and 1 in the example;java-frameworks' entries on Spring Boot 3.5.0 and 4.1.1 (the web tests with them), and the MCP end-to-end test againstwebserver.
- Medium. A client closed while the JVM stops (a Spring context's own shutdown hook calls
- The site pass: the Java docs pages (installing with Maven and Gradle, the client, Spring Boot,
@Scheduledand ShedLock, Quartz, a servlet container, a crontab), the landing page's languages, javadoc.io links, and the release that turnsjava.ymlon.
What is known before any code, mostly shared with the Go, Rust, and Elixir ports; phase 5 completes the list.
- A bad zone is refused when the expression is read rather than when a fire time is asked for. (A schedule naming a date no month has,
0 0 30 2 *, never fires, and a one-time date string is refused withCronPattern: a one-time date is not supported: since 0.11 these are the SDK's own answers, not departures from it.) - Zones come from the JDK's copy of the IANA database, which is as new as the JDK's last update, where Node's comes with its ICU; a zone that changed its rules recently can differ until the JVM is updated. Without a
timezone, a cron is read inZoneId.systemDefault(). - Error names are Java's simple class names and frames are Java's (
com.example.Reports.build (Reports.java:42)), not JavaScript's classes; a cause is not written. - Java cannot stop a running function;
interruptAtTimeout()asks it to stop, and a function that ignores interrupts runs on and is marked stuck by a check, as in the SDK. A run open when the JVM stops is recorded failed by the shutdown hook, where the SDK records nothing; a JVM killed outright records nothing, as a killed Node process does. - A value logged is a
String;JobContext.logtakes one, which the app formats, whereconsole.logjoins its arguments and writes objects as JSON. - A
HashMapgiven where the SDK keeps an object's order (abudgetorfloor, metrics given at once) is written in its own iteration order; aLinkedHashMapor the builder's setters keep the order given. Consolewrites toSystem.outandSystem.erras the SDK's does, but the error handler writes throughSystem.Logger, so its line carries the app's logging format.- The default transport sends
Java-http-client/<version>as its user agent where fetch sends its own, triage sendscronwatch-java/<version>, and the JDK writes its ownContent-Length,Host, andUser-Agentfirst and then the request's headers sorted by name without regard to case, names in the case given, where fetch keeps the order given; the tests pin what the JDK sends.host,connection,content-length,expect, andupgradegiven as webhook headers are dropped, since the JDK sets them itself. - A request that gets no answer fails with
<origin>: <the exception's simple name and message>(ConnectException,HttpConnectTimeoutException), in place of fetch'sfetch failed; a redirect is an answer outside 2xx (... answered 307), where fetch withredirect: "error"throws; either way nothing is sent where it points. An answer's head is held to the JDK's own limit (jdk.http.maxHeaderSize), not the Elixir port's 64 KiB. - A webhook URL whose host is outside ASCII is refused as
not this URL, since the JDK's IDNA is not WHATWG's, and so is one whose hostjava.net.URIdoes not read as a host (a name with an underscore); what WHATWG leaves in a path or query and theURIcannot hold ([,],^,|,{,}, a backtick, a lone%) is sent percent-encoded. - A Sentry DSN of a scheme WHATWG does not treat as special is refused when the options are built (
Sentry needs a valid dsn), where the SDK builds an endpoint the send then refuses. - Claude triage with no key fails each time it runs (
Anthropic triage needs an apiKey, or ANTHROPIC_API_KEY set), since the key is read when used, where the official client refuses when it is made. - A body past 1 MiB is answered 413; the SDK leaves the limit to the server in front of it. Behind Spring MVC, a form a filter ahead of the dashboard already parsed is taken from the container's parameters, which is the same answer for every body the dashboard's pages and
@cronwatch/mcpsend. - Tomcat, Jetty, and Spring Security's firewall refuse an encoded slash (
%2F) in a path by default, so a job whose name holds a/cannot be reached through the dashboard behind them without the app allowing it (Tomcat's connector settingencodedSolidusHandling, Jetty's URI compliance,StrictHttpFirewall.setAllowUrlEncodedSlash); the README says so, and the JDK's server passes it through. A request target a server refuses never reaches the routes. - A request target that is not a URI (
/cronwatch/jobs/%zz) is refused by the JDK's server, Tomcat, Jetty, and Netty with a 400 of their own before the routes see it, where the SDK answers its own 400 page; straight intoRoutes.handleit is the SDK's. Tomcat and Jetty write acontent-typeback without the space after its;(text/html;charset=utf-8), the same media type, and so does a WebFlux app that Spring Boot 3.5 serves on Tomcat. - An interval longer than 2^53 ms is held at 2^53 ms, since state times are
longs, as in the other typed ports; for the same reason a stored alert whoseatis not a whole millisecond reads as its whole part (so does its key), and so does asendingentry'suntil. An entry ofsendingthat is not an object is let go when the state is read, where the SDK lets it go at the next check. A silence ends as the SDK's does (silenceEnd): a whole millisecond, held at 2^53 - 1. - The dashboard's locked page and a handler's 503 name the Java API (
RoutesOptions.token,noToken(),HandlerOptions.noSecret(), and the starter'scronwatch.web.token), where the SDK names its options. - A job's handler function returns its value (
return nullfor none), since one functional type serves both a value and none (see Decisions, 42). - A run failed by an HTTP answer names the status's standard reason (
HTTP 503 Service Unavailable), from RFC 9110's table, where aResponsethe SDK's function builds itself has none (HTTP 503). - JSON nested more than 256 deep is refused, a request body so nested reads as none and a stored value so nested fails its row's read; a stored
matches /.../pattern that recurses past 512 frames or runs out of its budget does not match, and one of more than 4096 characters or with groups nested more than 100 deep is not read. These are the Rust port's bounds, kept for the same answers. - A job given
expectThatwith a different predicate is not declared again by a source (both storecustom function); the SDK compares the function's source text. - A development token needs
SecureRandom; without it none is made and the routes stay locked, where the SDK'scrypto.getRandomValuescannot fail. - The SDK has no scheduler integrations, no
deleteRunIf, nointerruptAtTimeout, and no shutdown hook.
The owner settled the draft's open questions before phase 1, taking each recommendation:
- The coordinates are the group
dev.cronwatch, verified on the Central Publisher Portal by a DNS TXT record oncronwatch.devthat the owner adds, with the artifact idscronwatch,cronwatch-servlet,cronwatch-spring-boot-starter,cronwatch-quartz(andcronwatch-jobrunrif kept), claimed by the first batched release after phase 1 works. The fallback, if the owner would rather not touch the DNS, isio.github.cronwatchdev. - Maven, with the wrapper committed; not Gradle.
- The floor is Java 21, tested on 21 and the newest release (and 25 for the tests alone, when the phase that needs it adds the entry).
- The JDBC store lives in the core (
dev.cronwatch.store, besideMemoryStore, since 0.11;dev.cronwatch.jdbc.SqlStore, its package before, is a deprecated alias that hands every call to it), not in acronwatch-jdbcartifact; the servlet adapter is an artifact of its own. - One starter for Spring Boot 3.5 and 4, tested on 3.5.0 and the newest 4.x, dropping 3.5 at the first release where it needs code of its own.
- The current run is a
ThreadLocal, removed infinally, carried byJobContext.wrapand Micrometer's context propagation;ScopedValuewhen the floor reaches 25; notInheritableThreadLocal. - The function types are generic in what they throw, so
runthrows exactly what the function throws, checked or not, with no wrapping. - The shutdown hook is on by default, marking runs still open in this JVM failed within five seconds through the conditional write, removed at
close();noShutdownHook()turns it off. - Wire values are small final classes around the SDK's strings, with constants for the known values, not enums.
- The environment is
CRONWATCH_ENV, thenAPP_ENV; in the starter, an active Spring profile when neither is set. An unset environment is not development. The profile is a guess, so it makes the dashboard's development token and the in-memory store's production warning but not a job's handler's opt-out: a handler without a secret runs only when a variable itself names development. Consolewrites toSystem.outandSystem.err, asconsole.infoandconsole.errordo; the error handler and the library's warnings go throughSystem.Logger.@Scheduledis watched through Spring's ownObservationof each invocation, from anObservationHandlerthe starter adds to the registrar's registry.@Scheduledjob names areSimpleClassName.methodof the bean's user class, the fully qualified name when two collide, with@CronwatchJob(name = ...)orcronwatch.jobs.<name>to name one.- ShedLock: the starter wraps the app's
LockProviderso a run whose lock was not taken is given back, and runs the check as a locked job once per cluster. - Quartz is built in phase 4, 2.5.0 or newer, as a
JobListener, with cron triggers checked against Quartz's own reading and the recovery rule for clustered stores. - JobRunr is built in phase 4 as
cronwatch-jobrunrfor JobRunr 8, and dropped from it if its server filters do not run in the job's thread or do not carry the failure. - The dashboard's filter sits ahead of Spring Security's chain, with
cronwatch.web.orderto move it behind. (Phase 5: a dashboard withcronwatch.web.open=true, which has no token of its own and is meant to sit behind the app's own sign-in, is ordered behind the chain, at -90, unlesscronwatch.web.ordersays otherwise.) - The step budget is the Rust port's 50 million steps with the same fail rule, and the 512 frames, 4096 characters, 100 groups, and 256 JSON levels. Phase 1 measured an exhausted budget on JDK 21 (21.0.12, an Apple M-series machine), three rounds over 32,000 characters each:
\n*\n*\n*\n*\n*xgave up in 131 to 132 ms,.*xin 139 to 159 ms,(?:a|b)*xin 125 to 146 ms and(?:a+a+)+xin 172 to 184 ms, far under the two seconds that would have lowered it, so it stands. CI's runner is slower; the property test that runs stored patterns holds each to 30 seconds, a bound for a real regression, not a measurement. - A command line:
CronwatchCli.main(factory, args)withcheck, called from amainof the app's own; arun <job> -- <command>wrapper or a standalone jar only if someone asks. - Publishing: the first publish by hand, checked in the Portal before it is published; then
java.ymlwith a Portal user token and a signing subkey in themavenenvironment with required reviewers, off untilMAVEN_ENABLEDistrue; trusted publishing, and the token revoked, when Central ships it. No second publisher on the namespace for now. - Static analysis is Error Prone at compile time with warnings as errors, google-java-format through Spotless, and
-Xlint:all -Werror; not SpotBugs. NullAway only if the audit finds nullness bugs it would have caught.
Phase 1 settled these, which the draft left open:
runandcall. A job's function with no value goes tojob.run(fn)and one with a value tojob.call(fn)(andcw.run(name, fn)andcw.call(name, fn)), rather than onerunoverloaded onJobRunnableandJobCallableas the draft had it.ExecutorService.submitgets away with that overload because its lambdas take no parameter, which javac counts as explicitly typed; a one-parameter lambda is implicitly typed, so javac finds both overloads applicable by arity alone and refusesnightly.run(job -> job.log("x"))as ambiguous, whatever the body returns. The two names follow the interfaces' own methods (Runnable.run,Callable.call), and the common case, a job with no value, keeps the SDK's name.JsObjectandJsonare public, in the exported packagedev.cronwatch.json: a state'sextra, aDefinition's fields, andJobOptions.fieldhand JSON values out and take them in, as the Rust port'sValueandObjectare public. The number writer, the date arithmetic, and the rest ofJsstay ininternal.- The properties are seeded JUnit tests over
Gen, a generator of the tests' own that names the seed of a failing case, not jqwik, and the tests are on JUnit 6: jqwik 1.10 holds the build to JUnit Platform 1.x, and it writes text of its own into the tests' output.-Dcronwatch.tries=Nmultiplies the tries for a longer run. - Micrometer's context propagation is found through
META-INF/servicesonly. The module does not declareprovides io.micrometer.context.ThreadLocalAccessor, since aprovidesnaming a type of a module required statically fails the module's resolution when that module is absent (ModuleTestresolves the module with none of its optional libraries). An app on the class path, which is every Spring Boot app, gets the propagation; a fully modular app without the library needs nothing. java.sqland JSpecify arerequires static transitive:SqlStore's API namesDataSourceandSQLException, and every nullable type carries@Nullable, so a module readingdev.cronwatchreads them too.- MySQL and MariaDB wait for phase 2.
SqlStore.of(dataSource)refuses them with a message saying so, and there is noSqlStore.mysqlyet;SqlStore.postgresholdssql.ts's statements, run against a server in phase 2. (Phase 2 addedSqlStore.mysql, andSqlStore.ofpicks it for MySQL and MariaDB.) - Text is written as JavaScript writes it. xerial's driver binds a lone surrogate as
?, soSqlStorebinds every string throughJs.wellFormed(U+FFFD, as better-sqlite3 writes it), and readsTEXTas bytes decoded with U+FFFD, not relying on the driver's decoding. - The engine reads what V8 reads without the
uflag, where the Rust port refused it: an escaped character outside the BMP is read as its two code units in turn. It refuses\0followed by a digit (a legacy octal escape V8 takes) and a quantified lookbehind, each with a message of its own, rather than read them differently. - An exception's name: the binary name only for an anonymous class, whose simple name is empty; a local class has a simple name, and that is used. A null message is written as an empty one (
IllegalStateException:), as the SDK writesnew Error(). onCancelcallbacks run on a virtual thread of the client's, not the timer's own thread, so one that blocks cannot hold up another run's timeout; one registered after the timeout has passed runs at once, where anAbortSignal's listener added late never runs.- Zones and croner's walk. A zone is a name in the JDK's own list, matched without regard to case, or a fixed offset written
+HH,+HHMM, or+HH:MM, so which aliases are taken follows the JDK's copy of the database. An error inside croner's walk ends the fires with none, as the Rust port does; a schedule with a bad pattern and a bad zone reports the pattern first. - The shutdown hook covers the runs of
runandcall, whose functions are running in the process; a handle left active is the stuck check's, as the SDK has it. A run whose function has returned has its recording waited for, within the same five seconds, rather than failed. - Everything the client writes runs on its own threads. Besides
run's recording, a handle'sstart,flush, andfinish,recordRun, the reads (jobs,runs,jobSummary), andsilencerun on the client's virtual threads and are waited for, so an interrupt of the caller never cuts a JDBC write in half; the caller's interrupt status is set again afterwards. A caller interrupted while it waits forcheck()gets aCronwatchException(its interrupt status set again) and the check goes on for the others. JobContext.wrapis overloaded onRunnableandCallable, as Micrometer'sContextSnapshot.wrapis; a lambda of an expression with a value handed where aRunnableis expected needs braces (ctx.wrap(() -> { x = 1; })), as withExecutorService.submit.- The build's settings. Error Prone's
MissingSummary,InlineMeSuggester,NarrowingCompoundAssignment,OperatorPrecedence,JavaTimeDefaultTimeZone, andStatementSwitchToExpressionSwitchare off, for the code that follows its originals line for line;-Xlint:allleaves outprocessing,requires-automatic(Micrometer's module is an automatic one), andtry(aStore, likeAutoCloseable, may throw anything fromclose()). Error Prone needs-XDaddTypeAnnotationsToSymbol=trueand the--add-exportsof.mvn/jvm.configon JDK 21 and newer. Surefire runs with--enable-native-access=ALL-UNNAMED, since xerial's driver loads a native library.
Phase 2 settled these, which the design left open:
Postand the URL reader are internal (dev.cronwatch.internal.post), since triage and the channels share them and an app never calls them; the public surface isTransport(with the request and answer it deals in,Transport.RequestandTransport.Response; see Decisions, 56) andJdkTransport, and each channel as a class (Slack.channel(SlackOptions), withSlack.webhook(url)andDiscord.webhook(url)for the common case) with an options class whose builder takes the SDK's option names. Options are refused frombuild()with the SDK's message in Java's words, the provider named as the SDK names it in its errors (Resend needs an apiKey,SES needs a region like us-east-1,New Relic needs a numeric accountId), never quoting a value.- The client owns one transport.
Cronwatch.builder().transport(t)gives the app's own (which the app closes); otherwise the client makes aJdkTransporton its first send and closes it with itself, so a client that never sends over HTTP starts noHttpClient.ChannelContextandTriage.Contextcarry it (Triage.Contextgained a third component, with a two-argument constructor that makes one on first use, for a context outside a client), and a channel's or triage's owntransport(...)wins. java.net.httpis required at run time (requires java.net.http, notstatic): a modular app that sends alerts would otherwise fail at its first send unless it added the module itself. It is the one JDK module beyondjava.basethe core requires at run time;ModuleTestholds it.- Triage reads its key when it runs, as the Rules read every variable, so a missing key is a reported failure of that triage rather than a refusal at
build(). JobOptions.describe(name)is public: the definition the options declare, before the client's defaults, which the pg_cron source compares to decide whether a job changed (the SDK'skeyOf, the Go port'sDescribeJob).
Phase 3 settled these, which the design left open:
- A handler's function is one type,
HandlerFunction(@Nullable Object handle(JobContext, Request) throws Exception): what it returns is the run's (aStringits output, aResponsethe answer, anything else answered with the run), and one that returns nothing writesreturn null. Two names, asrunandcallare, would each need a secondhandlermethod, and one overloaded name is ambiguous for a lambda of two parameters (Decision 22). - The web package reaches the client through
internal.core.WebAccess(one accessor with the bridge's since phase 5; see 57), hooksCronwatch's static initializer installs: a silence of any number of milliseconds, the environment as the client reads it, the cron secret's opt-out, the once-only report of a handler refused for want of a secret, and a run that hands back what it threw (Runs.executeCaught, whichexecuteis now written over). None of it is public API. Cronwatch.Builder.environment(name)is the environment read when neitherCRONWATCH_ENVnorAPP_ENVis set, read the same way; the Spring starter is to pass it the app's active profile (Decision 10) from its client bean, which is phase 4's file.Adaptersis public: the request target read back as the UTF-8 it was sent in, and a form body written back from a container's parsed parameters (less the query's, which the servlet specification puts first), for the servlet adapter, the starter, and an adapter of an app's own.- The starter's web part is its own:
dev.cronwatch.spring.web(CronwatchWebAutoConfiguration,CronwatchWebPropertiesforcronwatch.web.*,CronwatchServletFilter,CronwatchReactiveFilter), afterdev.cronwatch.spring.CronwatchAutoConfigurationby name and on the condition that the app has aCronwatchbean, so it neither makes nor needs the client bean, which phase 4 builds beside it.cronwatch-servletis a dependency of the starter, not an optional one, so a Spring MVC app needs the starter alone; its classes load only on a servlet app. - The routes answer a
RuntimeExceptionwith the SDK's 500 and report it asroutes; anErroris reported and thrown again, as the Rules have everyError.
Phase 4 settled these, which the draft left open:
- Runs seen from outside are
job.open(RunOptions)and anObservedRun, public indev.cronwatch, the two halves ofrun, paired through each scheduler's own object for the invocation rather than a per-thread stack; the starter's per-thread stack is ShedLock's alone.RunOptions.mayTakeBack()defers closing missed and stuck to the run's close, for a run its integration may give back (a method under@SchedulerLock). - The starter's check is an interval of its own, not
cw.startChecking(), so each integration's sync runs before each check, withcronwatch.check-mode(auto,local,shedlock,quartz,none): under ShedLock, the lockcronwatch-checkheld at least four fifths of the interval and at most ten intervals; under Quartz,CronwatchCheckJobon the clustered scheduler. @Scheduledjobs are declared from the annotations alone, read as Spring's own post-processor reads them, only when scheduling is on, with no confirmation against the registrar's tasks. The tag isspring-scheduled; a job is renamed by@CronwatchJob(name), then bycronwatch.jobs[<name>].name, whose other properties come after the annotation's.- The starter watches Quartz's
Schedulerbeans itself once every singleton is made, rather than throughSchedulerFactoryBeanCustomizer, whose package differs between Boot 3.5 and 4, so one starter needs no code per Boot line. No Boot class outsidespring-bootandspring-boot-autoconfigureis named, and the Quartz auto-configuration is ordered after both Boot lines' own by name. - The client bean's store: an app's
Storebean, elsecronwatch.store(auto:SqlStoreover the app's oneDataSource, Spring'sTransactionAwareDataSourceProxyunwrapped, falling back to the memory store with a warning when the store refuses the database;jdbc: the same, failing instead;memory). EveryChannelbean is a channel. The client's environment, when neitherCRONWATCH_ENVnorAPP_ENVis set, is the app's first active profile that reads as development or production, else its first (Decisions, 10 and 44). - JobRunr is kept as
cronwatch-jobrunr(Decisions, 16 asked it be checked): the filters run in the worker thread and carry the failure. A job that is not recurring is watched by its JobRunr name throughwatchJob; the starter does not register the filter, since JobRunr's own starter owns its server's filters. - The command line:
CronwatchCli.main(factory, args)exits only with a failure's status, andCronwatchCli.run(factory, args, out, err)answers the status and never exits. - The integrations' dependencies are
providedoroptional: Quartz incronwatch-quartzand JobRunr incronwatch-jobrunrareprovided; Spring Boot, ShedLock, Quartz, andcronwatch-quartzareoptionalin the starter, which imports Spring Boot's BOM at-Dspring-boot.versionso Spring's modules meet at one release (the parent's JUnit and SLF4J stay its own). Each module is named (dev.cronwatch.quartz,dev.cronwatch.jobrunr,dev.cronwatch.spring) and requires Spring, Quartz, and JobRunr by their automatic names, with-Xlint:-requires-transitive-automatic(and-exportsin the starter, whose auto-configurations name Spring's types).
Phase 5 settled these, which the audit raised:
- The transport's request and answer are
Transport.RequestandTransport.Response, nested indev.cronwatch.alerts.Transport, not the top-levelalerts.Requestandalerts.Responsephase 2 made. A Spring or servlet app that writes a transport or a job's handler importsdev.cronwatch.web.RequestandResponsebeside them, and two public pairs of the same simple names in one API had one of them always written in full.HttpRequestandHttpResponsewere weighed and left out, sinceJdkTransportand most apps importjava.net.http's types of those names;Transport.Requestnames what it is for, asMap.Entrydoes. The dashboard's pair keeps its names, being whatRoutes.handleandHandler.handletake and answer, and what every adapter builds. - One internal accessor,
internal.core.Access, in place of phase 3'sWebAccessand phase 4'sFriends: one interface of the client's side (Access.Client), whichCronwatch's static initializer installs once (a later call, from code on the class path reaching into the package JPMS does not export, changes nothing), for the dashboard, a job's handler, and the bridge alike, implemented by the package-privateFriend. - NullAway stays off (Decision 21): none of the audit's findings was a nullness bug it would have caught.
RunOptionskeeps its names: the static factories (trigger,interruptingAtTimeout,discardWhen) beside the instance methods (withTrigger,interruptAtTimeout,withId,withDiscardWhen,mayTakeBack), since Java refuses a static and an instance method of one signature (so the static one that interrupts isinterruptingAtTimeout).- What 0.11 changed in the API (the 1.0 plan, taken whole):
startChecking()and its overloads, withstart()kept (D21);SqlStoreindev.cronwatch.storebesideMemoryStore, withdev.cronwatch.jdbc.SqlStorea final class handing every call to it (Part 5);SchedulerBridge, the .NET name, withBridgea class of static methods handing every call to it, and the package outside the 1.x promise (D27);Routes.ofleft tocw.routes(D19). Each old name is@Deprecated(since = "0.11", forRemoval = true), works through 1.x and goes in 2.0, and a test holds it to its replacement. The port was never published before 0.11, so what was public only by accident is simply internal from 0.11 rather than deprecated (D25, D28):Json.quote,kind,copy, andMAX_DEPTH(nowJs.typeOf,Js.copyJson, andJs.JSON_MAX_DEPTHininternal.js, andJson.stringifyfor a string), pg_cron'sschedule,jobName,run,HOLD_MS, andPgCronRow(package-private),Twilio.MAX_SEGMENTS, and indev.cronwatch.storetesteverything butStoreContract.run(StoreReplay,FinishOnce, andForeignRowsmoved to the port's tests,newRunpackage-private). The records that may grow have static factories (D9; see The Java API). The site's Java page lists all of it under Deprecated.