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#

OptionRequiredMeaning
periodyesThe span of simulated time the run represents, e.g. 24h, 7d
inyesThe wall-clock budget to compress it into, e.g. 20m
fromnoRFC3339 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.

ScaledLeft 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.

FunctionReturns
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=40

The 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 1s fails. This catches the common case of leaving --duration at its 1m default under a large factor and getting an 833ms run.
  • A scaled tick interval below 10ms fails, matching the floor the QPS ramp ticker already applies. A worker written rate: 1000/1m cannot 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:

FeatureUse it for
simBinding the whole run - pacing and data - to a compressed timeline
seasonWeighting timestamps by month, weekday, and hour of day
signalA pre-computed waveform sampled by index, for correlated series