Skip to content

taskautomation-org/downsample-designer

Repository files navigation

Downsample Designer

Design an InfluxDB downsampling cascade in the browser: get the Flux tasks that implement it, an auditable storage estimate, and a check for the misconfigurations that quietly corrupt a rollup.

Open the live tool — no install, no account, no backend. Everything is computed in the page.

The problem

Every time-series deployment eventually has the same conversation. Raw telemetry arrives every few seconds, the disk is filling, and somebody proposes "let's just downsample it". Then the questions start, and none of them have obvious answers:

  • If we keep raw for 14 days and minute data for 90, how much does that actually save? Nobody can answer this without a spreadsheet, and the spreadsheet is always wrong, because the thing that dominates the number is not the sample interval — it is the series count, and the series count is multiplied by every aggregate function in every tier of the cascade.
  • Do we roll up hour data from the minute tier, or from raw? Cascading is cheaper. Cascading is also how you end up storing the mean of a mean, which is not the mean.
  • What does the Flux actually look like? The example in the docs is four lines with an unbounded range(start: -task.every). A production task needs its window boundaries truncated so it never writes a half-filled window, an offset sized to your ingest lag, and a lookback wide enough that late writes are not silently dropped.

Nothing about this is hard, exactly. It is just fiddly enough that people guess, and the mistakes are the kind you only discover months later — when the raw bucket has expired and you find out the rollup that was supposed to preserve the history has a gap in it, or that the "average" on your two-year dashboard has been quietly biased the whole time.

This tool makes the whole thing concrete. Describe your data, get the tasks, see the numbers with the arithmetic shown, and get told about the specific mistakes your design contains.

Demo

The screenshots below are captured from the running application by scripts/screenshots.mjs, against a worked configuration of 4 000 devices sampling three fields every five seconds.

The Downsample Designer interface. On the left, a Source panel with bucket, measurement, fields, a 5 second sample interval, 14 day retention and three tag keys with their cardinalities. On the right, a Findings panel listing five warnings and a Storage Estimate panel showing 572 TB for raw-only against 5.98 TB tiered, a 99.0% saving.

The same view in dark theme

The Downsample Designer interface in dark theme, showing the same source configuration, findings and storage estimate.

Generated Flux

Each tier produces a complete, copy-pasteable task with a copy button and a download.

The Generated Flux Tasks panel in dark theme, showing a syntax-highlighted task named downsample_sensor_reading_to_plant_telemetry_1m. It imports date, declares option task with every 1m and offset 45s, truncates the stop boundary to the 1m window, subtracts a 3m lookback, filters on measurement and three fields, drops the firmware tag, and builds three aggregateWindow pipelines for mean, min and max.

Storage estimate

The saving, the per-bucket breakdown, the cascade diagram — and an expandable panel that shows every step of the arithmetic, because an unexplained number is not usable for capacity planning.

The Storage Estimate panel in light theme. Headline figures read 572 TB raw only, 5.98 TB tiered, 99.0% saved and 22.39 billion raw points per day. A cascade diagram shows four buckets from plant_telemetry_raw at 5 seconds and 4.39 TB through to plant_telemetry_1d at 1 day and 149 GB. Below, a per-bucket table and an open panel showing the three formulas the model uses.

Two charts side by side in dark theme. On the left, resident storage over 1825 days: a steeply rising red line for raw-only reaching over 500 TB, and a flat blue line for the tiered design. On the right, a bar chart of steady-state volume per bucket, from 4.39 TB for the raw bucket down to 149 GB for the daily tier.

Findings

Every warning explains what is wrong in terms of your own numbers and links to a write-up of that class of problem.

The Findings panel in light theme showing errors and warnings, including a tier whose retention is shorter than its input's, an offset that is not shorter than the task interval, a window that is not a whole multiple of its input, and a cardinality warning naming device_id as contributing a factor of 4000.

Interaction

Four frames captured while changing the first tier's window from 1m to 1h. The Flux, the estimate and the findings all update on every keystroke.

The designer with the first tier window set to 1m. The same view with the window changed to 5m; the generated aggregateWindow call and the storage figures have updated.
The same view with the window changed to 15m. The same view with the window changed to 1h, showing the lowest storage figure of the four.

Run it from a clone

The tool is a static site. There is no server component and nothing to configure.

git clone https://github.com/taskautomation-org/downsample-designer.git
cd downsample-designer
npm install
npm run dev          # http://localhost:5173

To produce a deployable build:

npm run build        # writes dist/
npm run preview      # serves dist/ on http://localhost:4173

dist/ is plain HTML, CSS and JS. Drop it behind any static host, or open it from a file:// URL if your browser allows module scripts from disk. If you are hosting it under a sub-path, set BASE_PATH at build time:

BASE_PATH=/tools/downsampler/ npm run build

Requires Node 20 or newer.

Using it

1. Describe the source

Field Meaning
Raw bucket Bucket your writes land in. Becomes the from(bucket:) of the first task.
Organisation Used in the generated to(org:).
Measurement The measurement to roll up.
Fields Comma-separated field keys. Each field is a separate series per tag combination.
Tag keys Each tag's key, its number of distinct values, and whether the rollup keeps it.
Sample interval How often a point arrives per series, e.g. 10s.
Raw retention Retention of the raw bucket. 0s means keep forever.

The keep checkbox on each tag is the most consequential control on the page. Series count is the product of every retained tag, and it is what the index has to hold in memory. A firmware_version tag is useful on raw data and almost never useful on a daily rollup; unticking it divides the rollup's cardinality by its distinct-value count and adds a drop(columns:) to the first generated task.

2. Describe the tiers

Tiers form a cascade: the first reads the raw bucket, and every later tier reads the one before it. For each tier you set the target bucket, the aggregation window, the aggregate functions and the retention.

Aggregates are where the storage estimate gets interesting. Each function you tick writes its own series, and that multiplier compounds down the cascade — a three-aggregate minute tier feeding a two-aggregate hour tier writes six series for every input series. When a tier has more than one aggregate, the generated task tags each stream with an agg label and unions them into a single write.

3. Set the schedule

Field Meaning
every Task interval. Left blank, each tier uses its own window, which is almost always what you want.
offset How long after the window closes the task fires. Exists to wait out late writes.
cron Optional. Replaces every in the task option for wall-clock scheduling.
Late-data tolerance How late a write may arrive and still be picked up. Widens the query range; the extra windows are simply rewritten.

4. Take the output

Copy each task, or download them all as a single file, and create them with:

influx task create -f downsample_sensor_reading_to_telemetry_1m.flux

5. Share it

The whole configuration is encoded into the URL hash as you type. Copy the address bar — or use the Copy permalink button — and whoever opens the link sees exactly the numbers you were looking at. Nothing is stored anywhere; the link is the storage.

What the generated Flux looks like

For a first-hop tier with three aggregates, the tool produces:

// downsample_sensor_reading_to_plant_telemetry_1m
//
// Rolls up raw sensor_reading from "plant_telemetry_raw"
// (5 seconds resolution) into "plant_telemetry_1m"
// at 1 minute resolution, kept for 90 days.
// Aggregates: mean, min, max.
//
// Background on the window-alignment and idempotency pattern used below:
// https://taskautomation.org/automated-task-scheduling-orchestration/flux-scripting-for-task-automation/writing-robust-flux-scripts-for-automated-data-rollups/

import "date"

option task = {
    name: "downsample_sensor_reading_to_plant_telemetry_1m",
    every: 1m,
    offset: 45s,
}

// Truncating `stop` to the 1m window means the task only ever writes whole
// windows — a window that is still filling is never written and then rewritten with
// a different value. The 3m lookback re-reads recently closed windows so late
// arrivals are folded in; rewriting a window with `to()` is idempotent.
stop = date.truncate(t: now(), unit: 1m)
start = date.sub(d: 3m, from: stop)

source =
    from(bucket: "plant_telemetry_raw")
        |> range(start: start, stop: stop)
        |> filter(fn: (r) => r._measurement == "sensor_reading")
        |> filter(fn: (r) => r._field == "temperature" or r._field == "vibration")
        // Dropped at the first hop: these tags multiply rollup cardinality.
        |> drop(columns: ["firmware"])

agg_mean =
    source
        |> aggregateWindow(every: 1m, fn: mean, createEmpty: false)
        |> set(key: "agg", value: "mean")

agg_min =
    source
        |> aggregateWindow(every: 1m, fn: min, createEmpty: false)
        |> set(key: "agg", value: "min")

agg_max =
    source
        |> aggregateWindow(every: 1m, fn: max, createEmpty: false)
        |> set(key: "agg", value: "max")

union(tables: [agg_mean, agg_min, agg_max])
    |> sort(columns: ["_time"])
    |> to(bucket: "plant_telemetry_1m", org: "acme-industrial")

Three details are deliberate and worth calling out, because they are what separates this from the four-line example in the documentation:

The range is bounded on both ends and aligned to the window. date.truncate snaps stop down to a window boundary, so the task never aggregates a window that is still filling — the classic cause of a rollup value that changes after the fact. start is stop minus a lookback rounded up to a whole number of windows, so boundaries always line up.

The lookback is wider than the interval. It covers every plus your late-data tolerance. Recently closed windows get re-derived and rewritten every run, which is harmless: to() overwrites a point with the same timestamp and tag set, so the task is idempotent and late arrivals are folded in on the next pass rather than lost.

createEmpty is always stated. Leaving it to the default is how a rollup ends up either silently gapped or silently padded with nulls that consume a point per series per window for the whole retention period.

The storage model

The model is intentionally simple enough to check by hand:

points/day = (86 400 / resolution_seconds) × series
bytes/day  = points/day × bytesPerPoint
resident   = bytes/day × retention_days

Series counts follow the cascade. The raw bucket carries every combination of every tag multiplied by the field count. A rollup carries only the retained tags, times the product of the aggregate counts of every tier up to and including itself.

The baseline it compares against — "raw only" — is the cost of keeping raw data at full resolution for the entire horizon, which is the thing the cascade exists to avoid. The tiered figure is the raw bucket at its own retention plus every rollup bucket at theirs. Infinite retention (0s) is priced at the horizon, since an unbounded number is not a useful comparison.

bytesPerPoint is an input, not a constant, and that is the point. Real TSM compression depends entirely on the value distribution: a slow-moving counter can land under 2 bytes per point while a jittery float costs 16 or more. Measure your own buckets and put the real number in. The default of 16 is deliberately pessimistic.

What the model does not capture: WAL, index and tombstone overhead; series that report intermittently (real gaps make the estimate conservative); replication factor; and shard-group boundary effects, since a bucket only expires whole shard groups and so overshoots its nominal retention slightly. It is a well-founded order of magnitude for capacity planning, not an invoice.

Warning rules

Every rule fires only on something that is genuinely wrong or genuinely surprising, and every finding links to a write-up of that class of problem. A panel full of noise gets ignored, and then the one real finding gets ignored with it.

Rule Severity Fires when
parse-error error A duration field is not a valid Flux literal. Calendar units (mo, y) are rejected because their length varies, which breaks window alignment.
no-tiers error The cascade is empty, so the raw bucket carries the whole retention window at full resolution.
offset-ge-every error The task offset is not shorter than its interval, pushing each run into the following schedule slot.
window-finer-than-source error A tier aggregates into windows finer than the data feeding it, fabricating points instead of reducing them.
window-not-multiple error A tier window is not a whole multiple of its input resolution, so consecutive windows hold different sample counts and every aggregate is weighted by that count.
retention-gap error A tier expires before the bucket it summarises, so the long-range history it exists to provide disappears first.
count-of-count error A tier applies count to data that was already counted or summed, counting rollup rows instead of original events.
sum-of-mean error A tier sums averaged data, producing a value that scales with the number of parent windows rather than with the data.
mean-of-mean warning A tier applies mean or median to already-averaged data. Unbiased only if every parent window held the same number of samples; min, max, sum and last cascade cleanly.
every-shorter-than-window warning The task runs more often than a window closes, re-deriving unchanged windows.
cardinality warning A tier exceeds 100 000 series. Names the highest-cardinality retained tag.
negative-saving warning The tiers together cost more than simply keeping raw for the horizon.
mean-only note A first-hop tier keeps nothing but the mean, so excursions inside a window vanish permanently once raw expires.
create-empty note createEmpty is on, so a series that stops reporting keeps consuming a point per window for its whole retention.
no-offset note No offset anywhere, so every task fires the instant its window closes and misses writes still in flight.

Permalink format

The hash is v<version>.<base64url>, where the payload is short-keyed JSON. The default design encodes to roughly 300 characters. It is versioned so old links keep working, and compact rather than binary so you can decode one by hand and read what it says.

Decoding is total: a truncated, corrupted or foreign hash produces an explanatory message and the worked example, never a throw and never a half-applied configuration. A link written by a newer version says so rather than guessing at it. Missing individual values fall back to sensible defaults, and out-of-range numbers are clamped rather than rejected — a link that lost a character in a chat client should still be mostly readable.

How it works

src/lib/          Pure logic. No React import anywhere in here.
  duration.ts     Flux duration parsing and formatting
  types.ts        The domain model
  resolve.ts      Config -> resolved plan: durations, cascade wiring, series counts
  flux.ts         Plan -> Flux task scripts
  storage.ts      Plan -> storage estimate and growth curve
  warnings.ts     Plan + estimate -> findings
  permalink.ts    Config <-> URL hash
  palette.ts      Tier colours and axis rounding
src/components/   Presentation only. Charts and the cascade diagram are hand-written SVG.

resolvePlan() is the hinge. It parses every duration once, wires each tier to its predecessor, and computes series counts — so the generator, the storage model and the warning rules all read the same resolved structure instead of re-deriving it three times and disagreeing. Durations that fail to parse are collected with a dotted path and substituted with a neutral fallback, which is why the app keeps rendering while you are halfway through typing 1h30m instead of blanking out on every keystroke.

There is no state management library and no chart library. Charts are a few dozen lines of SVG each, which keeps the shipped bundle around 77 kB gzipped.

Development

npm run lint          # ESLint
npm run format:check  # Prettier
npm test              # Vitest unit tests
npm run coverage      # with a 85% floor on src/lib
npm run e2e           # Playwright, against the production build
npm run build

Unit tests cover the pure modules: Flux generation against golden files, the storage model against hand-computed values, every warning rule with both a positive and a negative case, and the permalink codec round-tripping plus a battery of malformed input. The E2E suite drives the built application through the main flow, the copy button, permalink sharing, theme switching and a set of accessibility invariants.

Further reading

Background on the problems this tool encodes:

Contributing

See CONTRIBUTING.md. New warning rules are especially welcome — if you have been bitten by a downsampling misconfiguration that this does not catch, that is a gap worth filling.

License

MIT. See LICENSE.

About

Interactive browser tool that designs InfluxDB downsampling cascades: generates Flux rollup tasks, models storage savings, and flags misconfigurations

Topics

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors