Blimp is an ACID cache and autonomous materialized view on Iceberg — an efficient per-core cache (2–4 GB/s per Blimp node) that keeps your AI/ML context fresh with CDC delta-merges served queries in 1-2s. One small, simple, scalable Blimp node runs in your own VPC; point your existing engines and Iceberg catalog at it to simplify your pipeline and lower inference and training cost. → blimp.software
blimp connects a Blimp node to your data which stays in your environment; Blimp
only reads it.
┌─ your Iceberg node (any cloud) ─┐ ┌──── Blimp node (AWS) ────┐
│ blimp CLI │ │ gateway │
│ Iceberg REST :8181 ──────────┼────▶│ S3 :9000 · router │
│ your S3 / MinIO data │ wire│ eblobbers │
└───────────────┬─────────────────┘ └───────────┬──────────────┘
└────── snapshot_changed webhook ─────┘
(your pipeline, on each Iceberg commit)
Vpc-mode reaches the Blimp node's gateway on its private IP with the Iceberg node's
IAM role (no keys); cross-region / cross-account / non-AWS reach it over public DNS.
blimp --setup detects which and wires it accordingly.
All three (same VPC, cross-region/peered, different cloud) are supported and proven by this kit for demo/test setup — pick whichever matches where your Iceberg node already lives. For production, run the Iceberg node in the same VPC as the Blimp node (same account, same region — ideally the same AZ): that path uses private IPs and the IAM instance role with no S3 keys, and every S3 call to the origin stays on the VPC S3 gateway endpoint with no internet egress. Cross-region, cross-account, or cross-cloud setups work, but add network hops and (outside same-account/region) real data-transfer egress cost on every read — fine for a demo or a one-off test, but not the recommended production topology.
blimp is a self-contained CLI — install it once, then it bootstraps its own
prerequisites on first --setup. Two supported ways to get it:
# 1. one-line installer (open repo, no Docker) — puts `blimp` on your PATH
curl -fsSL https://raw.githubusercontent.com/0chain/run-blimp/main/install.sh | sh
# 2. or just clone and run in place
git clone https://github.com/0chain/run-blimp && cd run-blimp && ./blimpYou need nothing pre-installed — blimp --setup installs what it uses
(docker for the catalog, python + pyiceberg, aws CLI, duckdb, unzip). Set
BLIMP_SKIP_DEPS=1 on hardened/offline hosts to manage deps yourself.
Docker alternative (optional): a prebuilt image bundles everything, for hosts where you'd rather not install anything on the OS:
docker build -t blimp-kit . && \ docker run --rm --network host -e CLUSTER_ID=… -e WAREHOUSE=s3://… blimp-kit --setup
--network hostlets it use the node's IAM role + reach the gateway private IP (that's what makes vpc mode key-free). Seedocker-compose.ymlto bring up the catalog + CLI together.
blimp list the commands
blimp --setup connect a Blimp node to your data (interactive)
blimp --query query optimizer + incremental CDC delta-merge
blimp --storage storage & read-through cache suite
blimp --acid ACID / linearizability check of all three data paths
blimp --bench author / materialize / delta-merge timing profile
Wiring is saved to ~/.blimp_env by --setup; every command reads it.
Running a command with no wiring offers to run --setup for you first.
Rough timing: --setup takes about 10-15 minutes end to end, --query about
5 minutes, --storage about 30 minutes (it drives the fullest benchmark
suite — warp, fio, mlperf, cache), and --acid about 5 minutes.
Zero-touch / CI: every prompt is skipped when its env var is pre-set —
export these (or source a file with set -a) and --setup runs unattended:
REGION CLUSTER_ID ICEBERG_URL WAREHOUSE ORIGIN_BUCKET NAMESPACE \
S3_KEY S3_SECRET # external mode only; blank/unset in vpc mode
GW GW_AK GW_SK # optional — auto-derived from CLUSTER_ID when unset
blimp.software → Create a Blimp node. Note the node id.
Fully interactive — every value is prompted with a default (Enter accepts); any env var already set skips its prompt (that's the zero-touch/CI path):
AWS region [ap-south-1]:
Blimp cluster id (from blimp.software): 1784970467881
It then runs the network assessment: probes the gateway's private IP.
Reachable → vpc mode (private IPs, IAM instance role, no keys typed).
Not reachable (other cloud/account) → external mode (public DNS
zus-<id>-0.zus.network, explicit S3 keys):
network assessment → MODE=vpc (gateway private 10.10.12.249 reachable: yes)
gateway → 10.10.12.249 · advertise this node as → 10.10.12.168 · S3 creds → blank (IAM role)
Remaining prompts: gateway (auto-filled), namespace, existing Iceberg REST URL
(blank = stand one up here via docker), S3 endpoint, data bucket,
warehouse (defaults to s3://<data-bucket>/wh), S3 keys (blank in vpc mode).
Guardrails --setup enforces (each is a real failure mode):
- Warehouse co-located with the data bucket. A warehouse in a different bucket breaks the gateway's catalog-metadata reads → MV author refuses ("grain not sampleable"). Divergence warns and offers to fix.
- Bucket access grant (vpc/same-account): applies a bucket policy for the gateway's instance role + your account — no silent 403 at author time.
- Blank keys = IAM instance role — the correct vpc answer; real keys are only ever typed in external mode.
What --setup does, in order:
-
Deps bootstrap — installs docker / python venv + pyiceberg / aws CLI / unzip if missing (
BLIMP_SKIP_DEPS=1to manage yourself). -
Network assessment — probes the gateway's private IP → picks vpc mode (IAM role, no keys) or external mode (public DNS, explicit keys).
-
Catalog — reuses your Iceberg REST URL, or stands one up here via
docker run(iceberg-rest on :8181 over your warehouse). -
Bucket grant (vpc/same-account) — applies the bucket policy so the gateway role can read; skipped in external mode (bring your own grant).
-
Saves the wiring to
~/.blimp_env(mode 600) for every later command. -
Wires the Blimp node over its admin API — no SSH, no restart:
POST http://<gateway>:9000/admin/source/configure Authorization: Bearer zus-<CLUSTER_ID> {"source":"customer","iceberg_url":"http://<node>:8181","warehouse":"s3://…/wh", "namespace":"…","bucket":"…","s3_key":"…","s3_secret":"…","s3_region":"…"}The gateway applies this to its live source config (
ZS3_SRC_CUSTOMER_*) — the very next query reads your data — and persists it so restarts keep the wiring. Keys travel over the authenticated admin API and are stored root-only on the gateway volume. On success--setupprints✓ cluster wired: source=customer … (live, no restart).
Finally it prints the same values for the Blimp node UI (Query Optimizer →
Production, the manual path) and the snapshot_changed webhook for your
pipeline.
venv/bin/python3 register_tpcds_tables.py \
--catalog http://localhost:8181 --warehouse s3://my-bucket/wh \
--source-bucket my-bucket --region ap-south-1 --namespace myns
(add_files registration — no data copy.)
Automatic (no SSH): --setup wires the cluster itself over the
authenticated admin API — POST http://<gw>:9000/admin/source/configure
(Authorization: Bearer zus-<CLUSTER_ID>). The gateway applies the source in
its live env (effective on the next query, no restart) and persists it
across reboots. Requires a gateway image ≥ 2026-07-27; on older images the
call fails gracefully and --setup prints the manual steps.
Manual fallback (older gateway image, or the admin-API call failed): paste
--setup's printed values into the Blimp node UI (Production tab). --setup
also retargets the read-through cache router at the new bucket itself
(POST /admin/cache/config) — that isn't a separate manual step.
External / cross-account mode requires S3_KEY/S3_SECRET; --setup
sends them in the /admin/source/configure body. In vpc mode leave them
unset — the gateway reads via its IAM role.
Firewall: the gateway must reach this node on the catalog port. If the Blimp node security group doesn't open 8181, serve the catalog on an open port (e.g.
docker run -p 8081:8181 …) and use that URL.
The --query, --storage, and --bench commands are testing/validation
tools — they prove the wiring, measure the node, and gate a rollout. They are
not part of production operation (production is your pipeline + the
snapshot_changed webhook from Step 4).
Defaults to q9 q88 q14 q64 q4 on store_sales: ① serve/author each →
② append rows to all five facts + fire the webhook → ③ re-run and report how
each MV refreshed. CDC is lazy: the webhook only marks the MV stale — the
next query pays the merge and reports merge_ms inline (read from the
phase-3 query response, with /admin/mv/wave/report as fallback):
q64 store_sales merge_ms=8856 mode=incremental
q4 store_sales author_ms=45048 no MV — served from base
There is no PASS/FAIL verdict. The suite reports what each query did and
you judge it: mode=incremental is the delta-merge fast path, no-delta is a
full re-author, and "no MV" means the query fell back to scanning base. Nothing
here asserts a threshold, because the interesting outcomes (a query that authors
no MV, a merge that silently measured unchanged data) are not binary.
Add --verify to check the merged rows are correct rather than merely fast —
off by default because the gateway runs no verification while serving, so an
unflagged run measures the production path.
Wider: SUITES="store_sales:3 19 43 52 55;store_returns:1" ./blimp --query
Join-CTE queries (q64-class, SUITES="catalog_sales:64") only see a delta
when the append touches both sides of the join — the seeder therefore
appends catalog_sales referentially (matching catalog_returns rows,
keys copied). A sales-only append correctly reports no-delta, not a bug.
Storage = warp S3 PUT/GET + TTFB, fio NFS, mlperf. The suite is
self-contained: it installs its own tools first (warp pinned v1.1.4, fio,
mount-s3, dlio) — BLIMP_SKIP_DEPS=1 opts out on hardened hosts, and a
section whose tool still can't install is skipped loudly. Cap sizes on small
nodes:
WARP_BUDGET_MIB=5120 FIO_JOBS=4 FIO_SIZE=1280M MLPERF_NUM_FILES=35 ./blimp --storage.
Bench = min/median/avg/max author / materialize / delta-merge profile
(ITERS AUTHOR_ITERS CDC_ROWS, all defaulting to 3/3/50000; BENCH_QNR=64
benches a different TPC-DS query from $Q_DIR/q<N>.sql instead of the built-in
q1; BENCH_FACT=catalog_sales appends referentially for join-CTE queries).
Check the n= on each summary line before quoting it — an append that
re-materializes instead of merging contributes no delta_merge_ms, so n can
be lower than ITERS. See the worked example in the walkthrough below for what
the numbers do and don't mean.
A Blimp node is not a single disk — a write is erasure-coded across many
independent blobbers, and reads are served through several front-ends (the
gateway S3 API, a read-through cache, a mounted filesystem). --acid proves
that this distributed stack still behaves like one correct store: a value you
just wrote is the value everyone reads, and a read that races an overwrite
never returns a stale copy or a torn mix of the old and new bytes.
ACID is OFF by default on the gateway, and
--acidturns it on for you. As of 2026-08-06 the gateway defaultsZS3_ACID_ALLto off: the warp benchmark that argued strict whole-file verify was free is the same tool that produced the phantomEOFerrors, so it cannot be used to justify paying that cost on every deployment.ZS3_ACID_BUCKETSstill keeps the MV warehouse buckets strict.
blimp --acidarms it viaPOST /admin/acidbefore the run and restores the previous setting afterwards — including on failure or Ctrl-C. That pin is a RUNTIME setting and is not persisted, so a gateway restart reverts toZS3_ACID_ALLregardless.If you run the porcupine checker by hand, arm it yourself first — a linearizability test against a gateway with ACID off is measuring the wrong configuration, and any torn read it reports says nothing about the ACID path:
curl -X POST http://$GW:9000/admin/acid \ -H "Authorization: Bearer zus-$CLUSTER_ID" \ -H 'Content-Type: application/json' -d '{"enabled":true}' # ... run the test ... curl -X POST http://$GW:9000/admin/acid \ -H "Authorization: Bearer zus-$CLUSTER_ID" \ -H 'Content-Type: application/json' -d '{"enabled":false}'Leaving it pinned on makes every LATER benchmark quietly pay the ACID cost — which is exactly how a misleading measurement gets made.
It uses porcupine, the same linearizability model-checker used in Jepsen distributed-systems testing. Many clients hammer the same keys with concurrent writes and reads; porcupine then searches for any ordering of those operations consistent with a single correct register. If none exists, the history is NOT LINEARIZABLE and the offending operation is reported. It runs against all three read paths, each under two profiles:
| Path | What it is |
|---|---|
| gateway S3 :9000 | the raw S3 API → gosdk → blobbers |
| router :8088 | the read-through cache S3 endpoint in front of the gateway |
| mountpoint-s3 | the gateway bucket mounted as a POSIX filesystem (the fio / mlperf / customer mount path) |
- single-writer — one client writes a key while N clients read it. This is the read-after-write guarantee an object store actually promises; a stale or torn read here is a genuine consistency bug.
- multi-writer — every client both writes and reads the shared keys, a stricter total-order probe. (High "errors" counts on the FUSE/router legs are just the client refusing two concurrent writers to one key — the verdict is over the operations that completed.)
blimp --acid
# tune with ACID_CLIENTS (8), ACID_KEYS (4), ACID_DURATION (45s)A clean run prints LINEARIZABLE for every leg — read-after-write is preserved
and no torn erasure-decode is ever exposed, whether you reach the node over S3,
through the cache, or as a mounted filesystem. --setup builds the checker (a
small Go program under acid/) automatically; it needs no configuration beyond
the gateway S3 keys already in your wiring. Run the checker from a box other
than the gateway (e.g. the Iceberg node) — co-locating the load generator on a
small gateway can starve it and produce spurious Illegal verdicts. (A
concurrent-read unexpected EOF from the warp load tool specifically is a warp
client artifact, not a consistency failure — verified separately with aws s3 cp md5 checks that pass byte-for-byte with the strict ACID verify on and off.)
Router cache-freshness guarantee. Router ON: an append still changes the served result (immutable Iceberg files = new keys = no stale hit). Router OFF: a source read writes zero bytes to the cache.
Blimp node stop/start (self-heal, no touch). Raw EC2 stop/start: private IPs
survive (in-VPC wiring reconnects untouched); public IPs change and the control
plane's 60s cron reconciles the DB + every zus-<id>-N DNS record (gateway
and blobbers) in ~1–2 min. MVs + wiring persist — re-run --query: the MV
serves without re-author, CDC keeps merging.
Credentials model.
- vpc mode: nothing typed. Iceberg node + gateway each use their IAM instance role (EC2 metadata); bucket access is a policy naming the role.
- external mode: S3 keys typed once into
--setup(~/.blimp_env, mode 600); pasted into the Blimp node UI they are stored in AWS Secrets Manager — only the ARN reaches Blimp node config, never state/logs. - Gateway admin API:
Authorization: Bearer zus-<CLUSTER_ID>.
- WALKTHROUGH.md — the same flow as a full transcript: every command and its real output, start to finish, on a fresh node.
- TESTING.md — testing the kit itself (
./test_kit.sh), not the Blimp node. - Product docs: docs.zus.network/zus-docs/webapps/blimp — the optimizer, incremental MVs (CDC), and the Prod-Query & MV API.
A fresh Ubuntu 24.04 EC2 node in the cluster's VPC, cluster 1784970467881.
Every line below is the actual command and its actual output from a live run.
1. Confirm the node's identity + IAM role (no keys anywhere).
$ whoami; hostname; hostname -I
ubuntu
ip-10-10-12-62
10.10.12.62 172.17.0.1
$ curl -s -H "X-aws-ec2-metadata-token: $TOK" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
ec2-ssm-role-1784970467881
2. One command does setup end-to-end (deps → mode → catalog → wiring).
blimp --setup on the bare box:
== blimp --setup — connect a Blimp cluster to this node's data ==
== checking prerequisites ==
installing: docker-compose-v2 # ← installs its own missing deps
✓ deps ready (python: /home/ubuntu/.blimp_venv/bin/python3)
network assessment → MODE=vpc (gateway private 10.10.12.249 reachable: yes)
gateway → 10.10.12.249 · advertise this node as → 10.10.12.62 · S3 creds → blank (IAM instance role)
standing up an Iceberg REST catalog on :8181 over s3://blimp-tpcds-sf1-aps1/wh3
Container iceberg-rest Started
Iceberg catalog up — cluster reaches it at http://10.10.12.62:8181
saved wiring -> /home/ubuntu/.blimp_env
================= POINT THE BLIMP CLUSTER AT THIS SOURCE =================
Iceberg REST URL : http://10.10.12.62:8181
Warehouse : s3://blimp-tpcds-sf1-aps1/wh3
Namespace : tpcds_sf1x
setup done — validate with: blimp --query (and blimp --storage )
(The installing: docker-compose-v2 line was later removed — the catalog now
starts with a plain docker run, so the client box needs only the docker
engine, no compose plugin.)
3. Register your parquet as Iceberg (once):
$ ~/.blimp_venv/bin/python3 register_tpcds_tables.py --catalog http://localhost:8181 \
--warehouse s3://my-bucket/wh --source-bucket my-bucket --namespace tpcds_sf1x
registered 24/24 tables into http://localhost:8181 ns=tpcds_sf1x
The remaining items are the TESTING commands — validation of the wired node, not production operation.
A. blimp --query — author + incremental CDC:
Five queries against a single fact, appending to all five facts each cycle so a
multi-fact query exercises a multi-fact merge. Live run, AWS SF1 cluster
1785550395356, 2026-08-01:
== CDC bench: cluster=1785550395356 gw=10.10.114.87 rows/append=5000 suites=[store_sales:9 88 14 64 4] ==
==== fact: store_sales (q9 q88 q14 q64 q4) ====
>> phase 1: serve/author all (force_author=0)
q9: author=? materialize=? cold_serve=2826ms mv=?x? (mv_h_0e90d0b63216)
q88: author=? materialize=? cold_serve=2668ms mv=?x? (mv_h_ae8ceee390cc)
q14: author=12252 materialize=? cold_serve=3134ms mv=?x? (none)
q64: author=? materialize=? cold_serve=2657ms mv=?x? (mv_h_ad47f860697f)
q4: author=45048 materialize=? cold_serve=2971ms mv=?x? (none)
>> phase 2: append +5000 to [store_sales store_returns catalog_sales catalog_returns web_sales] + snapshot_changed
tpcds.store_sales: +5000 rows -> snapshot 3827416906052195600
tpcds.store_returns: +5000 rows -> snapshot 686337096249913447
tpcds.catalog_returns: +1666 referential rows -> snapshot 6382376194974615624
tpcds.catalog_returns: +5000 rows -> snapshot 7838483972766842389
tpcds.web_sales: +5000 rows -> snapshot 4945461575749320971
>> phase 3: re-run all (incremental)
============================== CDC CONTRIBUTIONS ==============================
query fact mv_rows x cols author_ms merge_ms mode incr_ms
q9 store_sales ?x? ? 5879 incremental 2781
q88 store_sales ?x? ? 6507 incremental 2693
q14 store_sales ?x? 12252 - - 3031
q64 store_sales ?x? ? 8856 incremental 3875
q4 store_sales ?x? 45048 - - 2959
q9: MV mv_h_0e90d0b63216 — delta-merged
q88: MV mv_h_ae8ceee390cc — delta-merged
q14: no MV — served from base
q64: MV mv_h_ad47f860697f — delta-merged
q4: no MV — served from base
How to read it: mode=incremental with a merge_ms means the MV was refreshed
by an append-only delta-merge (the O(|MV|+|delta|) fast path). A query with no
MV serves from base and reports author_ms instead — q14 and q4 do that here,
which is a real gap, not a pass/fail. There is no PASS/FAIL line: the suite
reports what happened and you judge it.
An append that fails leaves nothing to merge, and phase 3 then measures
UNCHANGED data while still printing plausible-looking no-delta rows. The suite
now prints the seeder's full traceback and says so explicitly when that happens
— if you see APPEND FAILED, every number below it is meaningless.
B. Raw stop/start (all 4 instances) → self-heal + re-validate:
private IPs unchanged; all 4 public IPs changed
DNS reconciled by the 60s cron (no touch):
zus-1784970467881-0 -> 13.201.26.189 (gateway)
zus-1784970467881-1 -> 3.110.120.231 (blobber-1)
zus-1784970467881-2 -> 13.127.74.12 (blobber-2)
zus-1784970467881-3 -> 3.111.245.151 (blobber-3)
post-restart q1: merge_ms=5138 mode=incremental RESULT: PASS
C. External-cloud host (non-AWS box, over public DNS):
network assessment → MODE=external (gateway private unknown reachable: no)
live query over zus-1784970467881-0.zus.network:9000
{status: ok, rows: 1, author_ms: 575, query_ms: 2447, md5: 7a26dcec…}
D. blimp --storage numbers (2/1 cluster, 5 GB warp set):
warp S3 PUT 749 MiB/s · GET 1673 MiB/s (0 errors)
fio NFS write 1137 MiB/s · read 937 MiB/s
Those are from a 2/1 cluster on larger instances. On a small node (c6in.large,
2 vCPU) the same suite gave fio NFS write 75.0MiB/s · read 352MiB/s — the
cache leg is only meaningful when the origin set is large enough to exercise it
(a single 17 MB object measures nothing, and reports the router as slower than
direct S3 purely from per-request overhead). Size the set to the cluster.
E. blimp --bench — MV lifecycle timing profile:
Phase A cold-authors the same query AUTHOR_ITERS times (evicting the MV
between iterations); phase B appends and refreshes ITERS times. Live run, AWS
SF1 cluster 1785550395356, 2026-08-01:
== MV lifecycle benchmark v2: cluster 1785550395356 (authors=3, appends=3, upserts=3, rows/cycle=50000) ==
author[1] q1 COLD status=ok author_ms=24087 materialize_ms=4024 wall_ms=27393.2 mv=mv_h_aff2e89bc41f
author[2] q1 COLD status=ok author_ms=15188 materialize_ms=3773 wall_ms=18415.7 mv=mv_h_aff2e89bc41f
author[3] q1 COLD status=ok author_ms=15239 materialize_ms=3705 wall_ms=18522.2 mv=mv_h_aff2e89bc41f
append[1] refresh delta_merge_ms=? materialize_ms=3763 query_ms=2955 engine=duckdb wall_ms=21891.6
append[2] refresh delta_merge_ms=8321 materialize_ms=? query_ms=3151 engine=duckdb wall_ms=11930.5
append[3] refresh delta_merge_ms=6627 materialize_ms=? query_ms=3059 engine=duckdb wall_ms=10051.4
== SUMMARY (q1-scale MV over 50000-row commits) ==
one-time author_ms (fresh queries): n=3 min=15188 median=15239 avg=18171 max=24087 (ms)
one-time materialize_ms: n=3 min=3705 median=3773 avg=3834 max=4024 (ms)
one-time wall_ms: n=3 min=18416 median=18522 avg=21444 max=27393 (ms)
append delta_merge_ms: n=2 min=6627 median=7474 avg=7474 max=8321 (ms)
append refresh materialize_ms: n=1 min=3763 median=3763 avg=3763 max=3763 (ms)
append commit→answer wall_ms: n=3 min=10051 median=11930 avg=14624 max=21892 (ms)