Time Compression#
A day of production traffic takes a day to run. sim compresses it: declare the period you are simulating and the wall-clock budget you have for it, and every duration in the config is read as simulated time and divided by the resulting factor.
sim(period: '24h', in: '20m')The scale factor is period / in - 72× here. Nothing else in the config changes. A stage that ramps over 2 hours ramps over 100 seconds; a background worker written rate: 1/1h fires every 50 seconds.
The full config is at
examples/time_compression/crdb.edg.
Options#
| Option | Required | Meaning |
|---|---|---|
period | yes | The span of simulated time the run represents, e.g. 24h, 7d |
in | yes | The wall-clock budget to compress it into, e.g. 20m |
from | no | RFC3339 anchor for the simulated clock. Defaults to the moment the run starts |
There is one clock per run, so sim takes no name and a second declaration is an error.
period and in can be overridden per invocation with --sim-period/--sim-in, or their EDG_SIM_PERIOD/EDG_SIM_IN environment variables, making the values in the file the defaults:
EDG_SIM_IN=5m edg workload run --config workload.edg --url "$URL"The overrides only apply to a config that already declares a sim block; they never add one. from is not overridable.
What Scales, and What Doesn’t#
Durations are simulated time. Rates are not - a rate written qps: 1000 still means a thousand queries per real second, so the run keeps the declared shape at 1/factor of the volume.
| Scaled | Left alone |
|---|---|
stages duration, ramp_duration, and qps: ramp(..., d) | qps values themselves |
The phase of qps: signal(...) (see below) | The peak qps: signal(..., peak: N) asks for |
Query and transaction wait (think-time) | stats block rate and per-query stats_rate |
workers delay and rate | --request-timeout and --drain-timeout |
--duration and --warmup-duration | --max-duration |
The three that are already virtual by construction - signal intervals, season ranges, and timestamp_steps - are untouched: they were never keyed on the wall clock.
qps: signal('name', peak: N) is the one entry that sits on both sides of the table. N is a rate, so it is left alone like any other qps. But the buffer’s position advances one step per interval of simulated time, so a 288-point signal at 5m intervals traverses a period: '24h' run exactly once whatever you compress it into. The curve asks for an unscaled rate; when it asks for it is scaled. See Signal-Driven QPS.
stats stays on real time deliberately: it observes the run rather than participating in it, so you get one chart point per real second regardless of the factor. Timeouts stay real because the database’s latency does.
The Simulated Clock#
Two functions read the virtual clock. Both need a sim block and both are run-phase only.
| Function | Returns |
|---|---|
sim_time() | The current simulated instant, RFC3339Nano |
sim_progress() | How far through the run the clock is, 0 to 1 |
sim_time() is what puts compressed timestamps into your data:
run {
insert_event(type: exec)
`INSERT INTO events (ts, kind) VALUES ($1::TIMESTAMPTZ, $2)` (
sim_time(),
set(['view', 'add_to_cart', 'checkout'], [70, 20, 10])
)
}Nanosecond precision matters here: at 72× the clock moves far less than a second between iterations, and second precision would collapse a whole burst of concurrent inserts onto one timestamp.
A Day of Traffic in Two Minutes#
sim(period: '24h', in: '2m', from: '2024-06-03T00:00:00Z')
stages {
overnight(workers: 2, duration: 6h, qps: 40)
morning_ramp(workers: 20, duration: 4h, ramp_duration: 2h, qps: ramp(40, 600, 2h))
midday(workers: 20, duration: 8h, qps: 600)
evening_peak(workers: 30, duration: 4h, qps: 1200)
wind_down(workers: 5, duration: 2h, qps: 100)
}
workers {
hourly_rollup(type: exec, rate: 1/1h)
`INSERT INTO hourly_rollup (ts, event_count)
SELECT date_trunc('hour', $1::TIMESTAMPTZ), count(*)
FROM events
WHERE ts >= date_trunc('hour', $1::TIMESTAMPTZ)` (sim_time())
}
run {
insert_event(type: exec)
`INSERT INTO events (ts, kind, amount) VALUES ($1::TIMESTAMPTZ, $2, $3)` (
sim_time(),
set(['view', 'add_to_cart', 'checkout'], [70, 20, 10]),
uniform.float(1.0, 500.0, 2)
)
}The stages add up to 24 hours of simulated time, so the run takes 2 minutes. edg logs both readings per stage:
INFO sim period=24h in=2m scale=720x
INFO stage name=overnight workers=2 duration=30s simulated=6h0m0s qps=40The hourly rollup worker fires every 5 seconds, one row per simulated hour. The event timestamps span a full day:
SELECT
extract(hour FROM ts)::INT AS hour,
count(*) AS n,
repeat('#', (count(*)::FLOAT8 / max(count(*)) OVER () * 40)::INT) AS bar
FROM events
GROUP BY hour
ORDER BY hour;hour n bar
0 200 #
...
9 3001 ####################
...
19 6000 ########################################
...
23 497 ###Floors#
Compression has limits, and hitting one is an error rather than a silently degenerate run:
- A scaled duration below
1sfails. This catches the common case of leaving--durationat its1mdefault under a large factor and getting an 833ms run. - A scaled tick interval below
10msfails, matching the floor the QPS ramp ticker already applies. A worker writtenrate: 1000/1mcannot survive a 72× squeeze.
Staged runs ignore --duration entirely - they take their length from the stages - so the default never trips the floor there.
Relationship to Other Temporal Features#
sim is the answer to “how do I stop estimating iteration counts?” in Temporal Patterns. Where global_iter() patterns are keyed on how many iterations you happen to complete, sim_time() is keyed on elapsed time, so the shape of the data no longer depends on how fast the database is.
The three work together:
| Feature | Use it for |
|---|---|
sim | Binding the whole run - pacing and data - to a compressed timeline |
season | Weighting timestamps by month, weekday, and hour of day |
signal | A pre-computed waveform sampled by index, for correlated series |