README
¶
CronLite Benchmark and Diagnostic Harness
This tool measures the observable lifecycle of CronLite executions. It records API calls, scheduler timestamps, webhook callbacks, retry attempts, terminal status polling, optional PostgreSQL state, Prometheus metrics, correctness failures, and environment metadata.
It is test tooling. It does not change CronLite's API, schema, retry policy, scheduler behavior, or production defaults.
Quick start
With CronLite already running:
export CRONLITE_API_KEY="ec_..."
go run ./tools/benchmark \
--base-url http://localhost:8080 \
--api-key "$CRONLITE_API_KEY" \
--scenario smoke \
--output ./benchmark-output
The safe default is equivalent to --scenario smoke --sample-count 10. It
starts an embedded receiver, creates one benchmark-owned job, manually triggers
ten executions, waits for callbacks and terminal statuses, writes all three
outputs, and deletes the job.
Prefer the CRONLITE_API_KEY environment variable instead of --api-key in
shell history:
CRONLITE_API_KEY="ec_..." \
go run ./tools/benchmark --output ./benchmark-output
Managed local environment
The harness can start an isolated three-instance database-dispatch environment:
go run ./tools/benchmark \
--start-compose \
--diagnostic \
--scenario smoke \
--sample-count 10 \
--output ./benchmark-output
This uses tools/benchmark/docker-compose.yml, a unique
cronlite-benchmark-* Compose project, PostgreSQL 16, three CronLite instances,
database dispatch, two workers per instance, Prometheus metrics, and the
reconciler.
The environment is left running by default for inspection. To remove the harness-owned containers and volume after the run, explicitly authorize it:
go run ./tools/benchmark \
--start-compose \
--diagnostic \
--scenario smoke \
--cleanup-environment \
--allow-disruptive \
--output ./benchmark-output
Cleanup validation rejects project names that do not begin with
cronlite-benchmark-. Process-control commands accept only the services defined
in the benchmark Compose file.
Managed ports are:
| Component | Host address |
|---|---|
| CronLite instance 1 | 127.0.0.1:18080 |
| CronLite instance 2 | 127.0.0.1:18081 |
| CronLite instance 3 | 127.0.0.1:18082 |
| PostgreSQL | 127.0.0.1:15432 |
| Embedded receiver | 0.0.0.0:19090 |
Measurement modes
Black-box mode
Black-box mode is the default. It uses only interfaces available to a normal CronLite customer:
- REST API;
- health endpoints;
- execution inspection;
- webhook callbacks;
- Prometheus metrics when accessible.
It does not query CronLite's database.
Diagnostic mode
Enable read-only PostgreSQL inspection with:
CRONLITE_BENCHMARK_DATABASE_URL="postgres://user:pass@localhost/cronlite?sslmode=disable" \
go run ./tools/benchmark \
--diagnostic \
--scenario smoke \
--output ./benchmark-output
Every diagnostic query runs in a transaction initialized with:
SET TRANSACTION READ ONLY
Diagnostic mode observes execution creation, scheduling, firing, claiming, attempt start/finish, status, queue depth, active executions, connection count, and database size where permissions allow.
Scenario reference
| Scenario | Purpose | Typical cost | Guard |
|---|---|---|---|
smoke |
Create, trigger, receive, verify, inspect | seconds | none |
baseline |
CronLite health RTT, direct receiver RTT, optional DB RTT | seconds | none |
cold-warm |
First delivery versus a warmed delivery | seconds | none |
warm-sequential |
Warm-up followed by one-at-a-time manual triggers | seconds/minutes | none |
concurrent |
Configured concurrency levels and queue/tail behavior | minutes | bounded by flags |
control-plane |
Get/list/update/pause/resume/delete latency | seconds | none |
slow-receiver |
100 ms, 500 ms, 2 s, and near-timeout responses | minutes | none |
retry |
500/503/429/400, timeout, connection failure, eventual success | about 15 minutes | real production policy |
recurring |
Multiple real minute-resolution cron ticks | multiple minutes | none |
duplicate-race |
Stale requeue versus active delivery | minutes | --allow-disruptive |
crash-recovery |
Stop/restart a dispatcher during delivery | minutes | --allow-disruptive |
leader-failover |
Stop the leader and observe takeover | minutes | --allow-disruptive |
database-outage |
Pause/unpause harness-owned PostgreSQL | seconds | --allow-disruptive |
load |
Explicit high-load concurrent run | workload-dependent | --allow-disruptive |
Select more than one scenario with a comma:
go run ./tools/benchmark \
--scenario baseline,smoke,warm-sequential,concurrent \
--sample-count 100 \
--concurrency 1,5,10,25,50 \
--output ./benchmark-output
--scenario all expands to every scenario and therefore requires
--allow-disruptive. It includes real recurring ticks and production retry
backoff, so it is intentionally long-running.
Full local suite profiles
Use separate profiles for steady-state, saturation, real retry timing, and
fault injection. Keeping them separate prevents a destructive case from
contaminating the latency baseline and makes failures easier to reproduce.
The commands are standalone profiles; stop the prior managed stack or use
--cleanup-environment --allow-disruptive before starting another one because
managed stacks use fixed local ports.
Steady-state and edge-case coverage:
go run ./tools/benchmark \
--start-compose \
--diagnostic \
--scenario baseline,smoke,cold-warm,warm-sequential,concurrent,control-plane,slow-receiver \
--sample-count 100 \
--concurrency 1,5,10,25,50,100 \
--timeout 120s \
--fail-on-correctness \
--output ./benchmark-output/steady
Real minute-resolution scheduler ticks:
go run ./tools/benchmark \
--start-compose \
--diagnostic \
--scenario recurring \
--sample-count 3 \
--timeout 4m \
--fail-on-correctness \
--output ./benchmark-output/recurring
The complete production retry policy takes roughly 15 minutes because attempts use the unchanged 30-second, 2-minute, and 10-minute backoffs:
go run ./tools/benchmark \
--start-compose \
--diagnostic \
--scenario retry \
--retry-profile real-policy \
--timeout 120s \
--fail-on-correctness \
--output ./benchmark-output/retry
Saturation profile, producing 1,000 executions at each configured concurrency:
go run ./tools/benchmark \
--start-compose \
--diagnostic \
--scenario load \
--sample-count 1000 \
--concurrency 100,250,500,1000 \
--timeout 5m \
--allow-disruptive \
--fail-on-correctness \
--output ./benchmark-output/saturation
Crash, failover, duplicate-race, and database-outage profile:
BENCHMARK_REQUEUE_THRESHOLD=5s \
go run ./tools/benchmark \
--start-compose \
--diagnostic \
--scenario duplicate-race,crash-recovery,leader-failover,database-outage \
--sample-count 1 \
--timeout 120s \
--requeue-threshold 5s \
--allow-disruptive \
--fail-on-correctness \
--output ./benchmark-output/resilience
The five-second requeue threshold is deliberately unsafe and is accepted only
by the managed CRONLITE_ENV=benchmark stack. CronLite emits a P0 warning when
it is active. Normal environments reject a requeue threshold shorter than the
complete dispatcher retry window.
Retry profiles
--retry-profile real-policy uses CronLite's unchanged policy:
attempt 1: immediate
attempt 2: 30 seconds
attempt 3: 2 minutes
attempt 4: 10 minutes
--retry-profile fast-test is accepted so automation can request the profile,
but the retry scenario is marked skipped. CronLite currently has no runtime or
build-tagged test-only retry injection. The harness does not modify production
retry defaults to shorten a benchmark.
Recurring versus manual timing
CronLite recurring cron expressions have one-minute resolution. Recurring results measure scheduler accuracy and drift across real ticks. Manual triggers measure millisecond-level API, dispatch, callback, and persistence behavior. The report never merges the two sample groups.
Important flags
| Flag | Default | Description |
|---|---|---|
--base-url |
http://127.0.0.1:8080 |
CronLite customer-facing URL |
--api-key |
CRONLITE_API_KEY |
Bearer key; environment use is preferred |
--receiver-addr |
127.0.0.1:9090 |
Embedded receiver listen address |
--receiver-public-url |
http://127.0.0.1:9090 |
URL reachable from CronLite |
--scenario |
smoke |
Comma-separated scenarios or all |
--sample-count |
10 |
Measured samples per workload |
--concurrency |
1,5,10,25,50 |
Concurrent delivery levels |
--timeout |
45s |
HTTP and per-execution polling timeout |
--poll-interval |
100ms |
Terminal-status polling interval |
--diagnostic |
false | Enable read-only PostgreSQL observations |
--database-url |
environment | Diagnostic PostgreSQL URL |
--metrics-url |
/metrics on base port |
Existing Prometheus endpoint |
--retry-profile |
real-policy |
real-policy or fast-test |
--random-seed |
1 |
Reproducible data generation seed |
--keep-data |
false | Keep benchmark-created jobs |
--fail-on-correctness |
false | Exit 3 for critical correctness findings |
--allow-disruptive |
false | Authorize load/crash/outage scenarios |
--allow-non-local |
false | Authorize a non-loopback CronLite target |
--start-compose |
false | Start the isolated local stack |
--cleanup-environment |
false | Remove the owned stack and volume afterward |
Run go run ./tools/benchmark --help for the complete list.
Correlation and receiver behavior
Every intended execution gets:
- benchmark run ID;
- scenario name;
- UUID benchmark correlation ID;
- job ID;
- execution ID;
- attempt/event ID;
- target URL;
- sample number and warm-up marker.
The receiver verifies X-CronLite-Signature using constant-time HMAC-SHA256,
bounds callback bodies to 64 KiB, and records UTC nanosecond timestamps. It
detects:
- repeated execution callbacks;
- repeated attempt IDs;
- simultaneous callbacks for one execution;
- callbacks after terminal status was observed;
- invalid signatures;
- callback payload changes.
Receiver observations are the authority for duplicate HTTP side effects. A single final execution row does not erase evidence that a webhook was delivered more than once.
Timestamp provenance
Each measurement is labeled:
| Provenance | Meaning |
|---|---|
directly_observed |
Recorded by the benchmark client or receiver |
derived |
Calculated from two observations or a polling bound |
database_observed |
Read from CronLite's PostgreSQL state |
unavailable |
Unsupported, skipped, or missing with a reason |
Go's monotonic clock component measures durations inside the harness. UTC RFC3339-nanosecond timestamps correlate different processes. Cross-process durations therefore depend on clock synchronization.
Formulas
The report uses milliseconds:
api_trigger_latency_ms =
trigger_response_received - trigger_request_start
scheduler_lag_ms =
execution_created_at - intended_scheduled_at
queue_wait_ms =
claimed_at - execution_created_at
claim_to_dispatch_ms =
first_attempt_started_at - claimed_at
webhook_rtt_ms =
attempt_finished_at - attempt_started_at
receiver_processing_ms =
receiver_response_completed - receiver_arrival
retry_backoff_actual_ms =
next_attempt_started_at - previous_attempt_finished_at
retry_backoff_error_ms =
retry_backoff_actual_ms - configured_backoff_ms
end_to_end_delivery_ms (manual) =
receiver_arrival - trigger_request_start
end_to_end_delivery_ms (recurring) =
receiver_arrival - intended_scheduled_at
terminal_persistence_lag_ms =
first_terminal_poll_observation - final_callback_response_completed
CronLite does not persist the exact terminal status update time.
terminal_persistence_lag_ms is therefore an upper bound whose uncertainty
includes --poll-interval.
Statistics
Every timing distribution includes:
- sample count;
- minimum and maximum;
- arithmetic mean;
- median;
- sample standard deviation;
- p50, p90, p95, and p99 using nearest-rank percentiles.
The report warns when p90 has fewer than 10 samples, p95 fewer than 20, or p99 fewer than 100. It also reports throughput, success rate, retry rate, permanent failure rate, duplicate rate, signature-failure rate, HTTP status distribution, error classification, and queue/resource observations when available.
Warm-up samples remain in raw output but are excluded from steady-state statistics.
Output files
Every completed run writes atomically:
benchmark-results.json
benchmark-results.csv
benchmark-report.md
JSON
The JSON document has schema_version: "1.0" and retains full raw:
- environment and redacted configuration;
- API, callback, metric, database, and process-control observations;
- execution and attempt identifiers;
- timestamps and polling bounds;
- measurements with provenance;
- scenario failures and skips;
- correctness findings;
- limitations.
CSV
The CSV has one row per delivery attempt, or one execution row when no attempt is observable. Its stable columns are:
run_id
scenario
correlation_id
job_id
execution_id
attempt_id
attempt
status_code
signature_valid
duplicate
warmup
trigger_type
final_status
error_class
scheduled_at
fired_at
execution_created_at
attempt_started_at
attempt_finished_at
receiver_arrived_at
api_trigger_latency_ms
scheduler_lag_ms
queue_wait_ms
claim_to_dispatch_ms
webhook_rtt_ms
receiver_processing_ms
terminal_persistence_lag_ms
end_to_end_delivery_ms
measurement_provenance
Markdown
The report puts correctness failures before performance numbers and contains:
- Environment and configuration
- Correctness findings
- Scenario summary
- Baseline HTTP latency
- API latency
- Scheduler accuracy
- Delivery latency
- Retry timing
- Throughput
- Failure analysis
- Duplicate-delivery findings
- Resource usage
- Limitations
- Reproduction instructions
- Raw-file locations
The run header also records commit SHA, exact redacted command, start/finish times, and duration.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Run completed; configured correctness threshold was not violated |
| 1 | Setup, scenario runtime, process-control, or output failure |
| 2 | Invalid or unsafe configuration |
| 3 | Critical correctness finding with --fail-on-correctness |
Outputs are still written after scenario failures whenever setup and output storage remain available.
Safety
- Non-loopback CronLite targets require
--allow-non-local. - Duplicate, load, crash, leader, and database-outage scenarios require
--allow-disruptive. - Environment deletion requires
--start-compose --allow-disruptive. - Compose deletion only targets
cronlite-benchmark-*projects. - Created jobs are deleted unless
--keep-datais set. - API keys, webhook secrets, authorization headers, PostgreSQL credentials, and receiver behavior tokens are redacted from output.
- Request/response bodies and Prometheus responses are bounded.
- Diagnostic SQL is parameterized and read-only.
Do not point aggressive workloads at a shared or production service without explicit authorization and capacity planning.
Current instrumentation gaps
The harness reports these gaps instead of changing CronLite:
- Public execution responses omit delivery attempts even though the endpoint description says it returns them.
claimed_atexists in PostgreSQL but is not public.- Terminal status update time is not persisted.
- Attempt start/finish times are stored but not returned by the public API.
- Worker or instance identity is not attached to executions or attempts.
- Webhook payloads have execution IDs but no customer-supplied correlation ID.
- Retry backoff has no test-only runtime injection.
- Exact final-response-to-terminal-persistence time is unavailable; API polling provides only a bound.
- A database claim transaction commits before HTTP delivery, so stale requeue behavior must be verified from receiver side effects.
General limitations
- Recurring cron resolution is one minute.
- Cross-process timings depend on synchronized clocks.
- Same-machine Docker introduces virtualization and contention noise.
- Local results are regression baselines, not universal capacity guarantees.
- Tail percentiles need adequate samples.
- Production network conditions can differ substantially.
- Diagnostic database visibility is not customer-visible behavior.
- A benchmark can reveal duplicates but cannot mathematically prove every race is absent.
- Missing Docker, PostgreSQL, permissions, or platform features produce explicit skipped scenarios.
Example output
tools/benchmark/example-output contains a locally generated example. Read its
environment and scenario status before interpreting measurements. If Docker was
unavailable on the generating machine, the report marks the managed CronLite
scenario as skipped/synthetic rather than inventing production measurements.
Documentation
¶
There is no documentation for this package.