big-state — Kilolock at scale
A Terraform configuration sized to make the Kilolock value proposition concrete: tens to hundreds of thousands of resources, a state file in the megabytes, and queries that still answer in milliseconds.
Everything here uses credential-free providers (random, null). No cloud account, no IAM, no network calls past the initial provider download.
What it produces
| Variable | Default | Resources | Edges | State size (raw JSON) |
|---|---|---|---|---|
size = 100 | — | 208 | 415 | ~100 KB |
size = 1000 | — | 2,006 | ~4,100 | ~1 MB |
size = 5000 | — | 10,006 | ~20,500 | ~5 MB |
size = 50000 | default | 100,006 | ~205,000 | ~50 MB |
Topology: one root deployment (random_pet + random_id + null_resource + summary), one primary_herd module with var.size (tag, label) pairs anchored by a leader, and one shadow_herd instance sized 1 that depends on the primary herd's leader. Resource count is linear in size (the herd module is structured to avoid quadratic dependency-edge blowup — see the comment at the top of modules/herd/main.tf).
Expected apply times
Measured on Apple M1 / Colima Postgres, single-threaded apply:
size | Apply time | Notes |
|---|---|---|
100 | ~1 s | Sanity check; always run this first. |
1000 | ~10–15 s | Comfortable for iterating on queries. |
5000 | ~1.5 min | Representative of "big" production states. |
50000 | ~10–15 min | The "ouch, that's why we built Kilolock" experience. |
Apply time is almost entirely Terraform itself — graph build, per-resource provider RPCs, state serialization. The Kilolock backend handles each POST quickly; the bottleneck is Terraform's own per-resource overhead.
Prerequisites
kldreachable onhttp://localhost:8080(the default local compose does this out of the box).terraform initrun in this directory so the CLI can discover the backend/runtime endpoint automatically.
Backend modes
This example keeps the quick local backend active in:
backend.tf
That file is meant for the default OSS quick-start stack: one runtime on http://localhost:8080, open auth, and no separate control plane.
For the default local OSS stack, use the direct state endpoint shape:
- state reads/writes:
GET/POSTon/v1/states/... - lock acquire:
LOCKon/v1/states/... - lock release:
UNLOCKon/v1/states/...
For hosted/cloud deployments behind managed edges, prefer:
- lock release:
POSTon/v1/state-unlock/...
That means the local OSS quickstart and the cloud-friendly backend shape are slightly different on unlock behavior.
If you want to run big-state against the prod-like compose instead:
- Copy the prod-like sample over
backend.tf:
cp ../local-backend/backend.tf.prodlike ./backend.tf
- Edit the copied
backend.tfwith your real:workspace_idenv_public_id- token secret
- Re-initialize Terraform:
rm -rf .terraform .terraform.lock.hcl
terraform init
Only one backend block may be active at a time, so avoid keeping two backend .tf files enabled in this directory.
Option A: local Postgres + local kld
One-shot setup from the repo root:
make db-up
make build
DB='postgres://kl:kl@localhost:5432/kl?sslmode=disable'
KL_DATABASE_URL=$DB ./bin/kld migrate
KL_DATABASE_URL=$DB ./bin/kld serve &
Option B: Docker Compose (default OSS path)
From the repo root:
cp .env.example .env
docker-compose up -d --build
This is the same default stack described in the top-level README: one Postgres, open auth, runtime on http://localhost:8080, and no separate control service. After terraform init, the kl CLI discovers that backend/runtime endpoint from this directory automatically.
Option C: prod-like compose + control plane
From the repo root:
cp .env.example .env
docker-compose -f docker-compose.prodlike.yml up -d --build
docker-compose -f docker-compose.prodlike.yml exec klc klc migrate
docker-compose -f docker-compose.prodlike.yml exec klc klc init \
--tenant self-hosted \
--tenant-name "Self Hosted" \
--token-name operator-bootstrap
Then:
- Copy the bootstrap token printed by
init. - Open
http://localhost:8090/portal. - Paste the token into the auth box.
- In Create Workspace, enter an optional human-friendly label/name, then create the workspace.
- In Workspaces, copy the real
workspace_id(ws_...). - In Create Environment, paste that
workspace_id, choose an environment label such asprod, then create the environment. - In Environments by Workspace, load that workspace and copy the environment's
env_public_id(env_...). - In Create Token, paste:
- the same
workspace_id - the environment
env_public_id - a token name such as
terraform
- the same
- Create the token and copy the raw secret shown once (
kl_...). - Copy
examples/local-backend/backend.tf.prodlikeoverbackend.tf. - Fill in:
username = "ws_..."password = "kl_..."- backend path
.../v1/states/{workspace_id}/{env_public_id}/big-state
- Run:
rm -rf .terraform .terraform.lock.hcl
terraform init
The demo scripts in this directory will then follow the active backend configuration automatically.
Lock/unlock methods
Why does this example use:
lock_method = "LOCK"
unlock_method = "UNLOCK"
unlock_address = ".../v1/states/..."
Because that is the simplest shape for the default local compose stack and it matches the backend's direct state endpoint behavior.
If you are running behind a managed ingress or load balancer that may not forward UNLOCK with a body reliably, use the cloud-friendly shape instead:
lock_method = "LOCK"
unlock_method = "POST"
unlock_address = ".../v1/state-unlock/..."
Today Kilolock does not expose a matching POST /state-lock/... alias, so do not change lock_method to POST unless we implement that route in the backend first.
Run the demo
From this directory:
# 1. Sanity-check the demo at small scale (~1 second).
terraform init
terraform apply -auto-approve -var=size=100
# 2. Step up.
terraform apply -auto-approve -var=size=1000
# 3. The "real" demo (~10–15 minutes).
terraform apply -auto-approve # uses the default size=50000
# 4. Tear down whenever (also slow at high sizes).
terraform destroy -auto-approve -var=size=50000
Quota-aware workflow
When this demo points at a hosted or entitlement-limited deployment, prefer:
./bin/kl quota remaining
terraform plan -out=plan.tfplan
./bin/kl quota check --tf-plan plan.tfplan
./bin/kl plan -f slow_a.tf -o slow-a.plan.json
What to expect:
kl quota remainingshows current headroom for the active state and its environmentkl quota checkuses Terraform's own plan output and asks the backend whether the projected resource count still fitskl planperforms the same style of quota preflight automatically when it can discover the HTTP backend state- soft-limit overages warn but still exit
0 - hard-limit overages fail before
kl apply
This is intentionally stronger than plain terraform apply against the HTTP backend alone. Plain Terraform can still create infrastructure and only learn about quota rejection when the final state write is refused; Kilolock CLI preflight exists to catch that earlier.
When KL_PROTOCOL=state-engine is enabled and the backend proves a safe native slice, ordinary operator-facing flows such as kl plan -f slow_a.tf followed by kl apply -f slow_a.tf stay on the trusted state-engine lane and commit as a narrow state-engine delta, while still advancing the canonical full-state serial normally.
To point at a different kld instance, override at init time:
terraform init -reconfigure \
-backend-config="address=http://kl.example.internal/v1/states/big-state" \
-backend-config="lock_address=http://kl.example.internal/v1/states/big-state" \
-backend-config="unlock_address=http://kl.example.internal/v1/states/big-state" \
-backend-config="unlock_method=UNLOCK"
Big-state demos
This directory intentionally carries six supported public demos:
./wait-demo.shfor same-resource contention and visible waiting./parallel-demo.shfor different-resource parallelism on one shared state./drift-demo.shfor refresh-style drift detection on the same large-state fixture./state-engine-demo.shfor state-engine mixed-graph scoping, removed-config classification, plus nativekl state mv/kl state rm./state-engine-benchmark.shfor repeatable ADR29 lane comparisons: plain Terraform target, KL full-trunk scoped plan, and KL state-engine native-slice cold/warm timing on the same fixture./migration-demo.shfor local <-> KiloLock roundtrip and the difference between current-snapshot migration vs native history-preserving archive export/import
Everything else in this directory exists only to support those demos or the Terraform fixture itself.
time_sleep.slow_a and time_sleep.slow_b are the two root-scope slow resources that make the collaboration story tangible. Each one sleeps for ~30 seconds during apply and each one is driven by its own version variable:
slow_a_versiononly replacestime_sleep.slow_aslow_b_versiononly replacestime_sleep.slow_b
State-engine demo
This is the experimental V3 lane. It is the best place to see what Kilolock can do beyond plain Terraform/OpenTofu HTTP-backend semantics, but it should be read as an advanced demo rather than the default day-one workflow.
Once the fixture exists and terraform init has been run here:
./state-engine-demo.sh
What it demonstrates:
- backend-assisted
kl plan -f ...when the selected file depends on an undeployed support node in a different file - preservation of required config-only support blocks in the scoped workspace
- trusted native
kl apply --file ...where the selected file safely widens to a realized support mutation plus an undeployed helper node - module-aware native scoping where a selected local module file widens to realized
module.*resource writes and still commits via the delta lane - realized dependency fetch through the state-engine protocol
- comparison of full backend state size versus the fetched slice payload
- preview-time classification of
removed { from = ... }addresses that are intentionally no longer in config - backend metadata showing
removed_config_nodesand safe handling when those addresses are already absent - native
kl state mvpreview/apply underKL_PROTOCOL=state-engine - move-back restore so the fixture stays canonical
- native
kl state rmpreview/apply - restore of the removed address via
kl rollback resource
What it does not prove yet:
- a backend-executed apply engine
- delta-only apply commits for generic
kl apply - complete replacement of Terraform/OpenTofu planning logic
Today the reference implementation still runs Terraform locally for planning and apply execution, then uses the state-engine protocol for scope expansion, reservation, coarse Terraform-visible locking, and commit.
Useful modes:
./state-engine-demo.sh scope
./state-engine-demo.sh native
./state-engine-demo.sh all
State-engine benchmark
When you want numbers instead of a narrative demo, use:
./state-engine-benchmark.sh plans
This now compares the same narrow change across three planning lanes:
- plain
terraform plan -target=...over the full trunk state kl plan -f ...withKL_PROTOCOL=terraform-http(current scoped/full-trunk lane)kl plan -f ...withKL_PROTOCOL=state-engine(native-slice lane, cold then warm)
It prints:
- end-to-end wall clock
- full-trunk versus native-slice payload context
- client timings for resolve / expand / fetch
- server timings for expand / slice materialization
- graph diagnostics such as realized rows, walked nodes, and scans
- slice payload counts
Optional modes:
./state-engine-benchmark.sh apply
./state-engine-benchmark.sh all
Migration demo
Use migration-demo.sh when you want to show that a team can move the same big-state fixture:
- from a plain local backend
- into KiloLock using the backend defined in
backend.tf - back to a plain local backend again
while also making it obvious that current-snapshot migration is not the same as native history-preserving archive export/import.
What it demonstrates:
- the real
examples/big-stateTerraform fixture can start locally first - the same fixture can then migrate into the KiloLock HTTP backend
- the same fixture can continue changing while KiloLock is active
- the current snapshot can migrate back to a plain local backend
- re-importing only the final snapshot does not preserve the original
append-only
state_versionshistory
Prerequisites:
terraformonPATHbackend.tfalready configured for the KiloLock runtime you want to test- optional but recommended:
klavailable either in./bin/klor onPATH
The script uses the actual big-state Terraform configuration, but at a small size by default so it stays practical.
Run it from the repo root:
examples/big-state/migration-demo.sh
Or override the state names / test size:
KL_STATE_NAME=big-state \
KL_REIMPORT_STATE_NAME=big-state-reimported \
MIGRATION_SIZE=10 \
examples/big-state/migration-demo.sh
The script copies the big-state Terraform configuration into a temporary work directory, runs the first phase without backend.tf to get a local state, then enables the exact backend block from examples/big-state/backend.tf for the KiloLock phase, and finally removes it again for the local roundtrip.
To keep the walkthrough practical, the script intentionally does not copy slow_a.tf and slow_b.tf. Those files exist for the wait/parallel demos and would otherwise add the built-in 30-second time_sleep delays to a workflow that is trying to demonstrate backend migration, not reservation behavior.
What to watch for:
- local
big-statefixture created successfully - state migrated to KiloLock using the backend block from
backend.tf big-stateresources changed while KiloLock was active- state migrated back to local and changed again with plain Terraform
At the end it re-imports the final local snapshot into a fresh KiloLock state name and prints both histories:
- the original KiloLock-backed
big-statehas multiple versions - the re-imported state starts with only one imported version
That is the current, expected behavior.
The collaboration scripts use narrow file-scoped plans under the hood so the demo spends its time showing reservation behavior, not replanning the entire state.
If you are deciding which demo to run, use this quick guide:
| Demo | Script | Purpose | What to watch for |
|---|---|---|---|
| Same-resource contention | ./wait-demo.sh blocker + ./wait-demo.sh waiter | Show that Kilolock does not let two engineers mutate the same branch blindly. | The second apply waits on the reservation and then proceeds after the first commit. |
| Different-resource parallelism | ./parallel-demo.sh slow_a + ./parallel-demo.sh slow_b | Show that two engineers can keep moving on one large shared state when their write sets are disjoint. | Total wall-clock stays close to one 30-second apply, not two serial ones. |
| Drift detection | ./drift-demo.sh | Show that refresh-discovered drift becomes a cheap backend query, not a custom state-file diff workflow. | current_resource_drift immediately shows the changed resource and the before/after note value. |
1. Same resource → visible waiting
Use wait-demo.sh when you want to show what happens when two engineers try to change the same resource.
# Terminal 1 # Terminal 2
./wait-demo.sh blocker ./wait-demo.sh waiter
Or single-terminal:
./wait-demo.sh both
What this demonstrates:
- terminal 1 acquires the orchestrated reservation on
time_sleep.slow_a - terminal 2 detects the orchestrated conflict on the same address
- terminal 2 prints the wait-status block every few seconds
- once terminal 1 commits, terminal 2 proceeds
This is the “same branch of state” story. Internally the script scopes planning to slow_a.tf, applies with explicit --orchestrated, and if the blocker advanced trunk in the meantime the waiter automatically replans once before retrying. That keeps the demo aligned with the real stale-plan safety model rather than whole-state backend locking.
2. Different resources → parallel apply saves time
Use parallel-demo.sh when you want to show what happens when two engineers change different resources in the same state.
# Terminal 1 # Terminal 2
./parallel-demo.sh slow_a ./parallel-demo.sh slow_b
Or single-terminal:
./parallel-demo.sh both
What this demonstrates:
- one engineer changes
time_sleep.slow_a - the other changes
time_sleep.slow_b - the write sets are disjoint
- both orchestrated applies run at the same time
- total wall-clock stays close to one 30-second sleep instead of two
This is the “we reserve only the affected branch, not the whole state” story. Internally each side plans only its own file (slow_a.tf or slow_b.tf) and then applies with explicit --orchestrated, which keeps the demo honest and snappy even when the rest of the state is large.
3. Drift detection → inspect one changed resource immediately
Use drift-demo.sh when you want to show that once refresh-discovered drift has been written, answering "what drifted?" is a cheap backend query instead of a custom state-file diff.
./drift-demo.sh
What this demonstrates:
- the demo simulates out-of-band drift on
null_resource.summary - it records that drift through
source='refresh' current_resource_driftanswers the question directly- the script restores the canonical state afterwards unless
KEEP_DRIFT=1
This keeps the drift story in the same large-state example as the parallel collaboration demos.
One-time bootstrap on a fresh state
Before running the collaboration demos, make sure the state reflects the default values currently committed in this directory. The simplest safe bootstrap is to apply the example once with its defaults:
terraform apply -refresh=false
That seeds the canonical trunk shape the demo scripts expect.
If you want the smallest possible bootstrap for just the two slow demo resources, run:
terraform apply -refresh=false \
-target=time_sleep.slow_a -target=time_sleep.slow_b \
-var=slow_a_version=v1 -var=slow_b_version=v1
Use the targeted bootstrap only if those values still match the defaults in the checked-in HCL. If you changed the code locally, prefer the plain default apply above so the demo starts from exactly what is in the files.
One-time cleanup after old demo drift
If you previously experimented with extra slow resources and one of the demos complains about stale trunk state, reconcile once with:
terraform apply -refresh=false -var=size=400
That brings the state back to the canonical demo shape.
What to look at while it's applying
Even mid-apply, the backend is queryable. The state-versions table records every POST, so you can watch the import progress in real time from another terminal:
watch -n 1 'kl query "SELECT serial, terraform_version, created_at FROM state_versions sv JOIN states s ON s.id=sv.state_id WHERE s.name='\''big-state'\'' ORDER BY serial DESC LIMIT 5"'
Queries to demonstrate the value
Measured below against a ~6,000-resource state (partial apply of size=5000):
Inventory by type — 22 ms
kl query "SELECT type, count(*)::int AS instances
FROM resources r JOIN states s ON s.current_version_id = r.state_version_id
WHERE s.name='big-state'
GROUP BY type ORDER BY instances DESC"
type instances
random_id 3132
random_string 2827
null_resource 1
random_pet 1
Inventory by module — 22 ms
kl query "SELECT COALESCE(NULLIF(module_path,''), '(root)') AS module,
count(*)::int AS resources
FROM resources r JOIN states s ON s.current_version_id = r.state_version_id
WHERE s.name='big-state'
GROUP BY module ORDER BY resources DESC"
Transitive blast radius from the herd leader — 133 ms
kl query "WITH RECURSIVE reach AS (
SELECT r.id, r.address, 0 AS depth
FROM resources r JOIN states s ON s.current_version_id = r.state_version_id
WHERE s.name='big-state' AND r.address = 'module.primary_herd.random_id.leader'
UNION
SELECT r.id, r.address, reach.depth + 1
FROM reach
JOIN resource_dependencies rd ON rd.to_resource_id = reach.id
JOIN resources r ON r.id = rd.from_resource_id
)
SELECT count(*)::int AS reachable_resources FROM reach"
A recursive descent that reaches every resource transitively depending on a single anchor — on 6k nodes — in 133 ms. The flat .tfstate equivalent is "parse the whole file into memory, build an in-memory graph, BFS, count." Trivially correct on small states; intolerable on big ones.
State size on disk
kl query "SELECT
pg_size_pretty(octet_length(sv.raw_state::text)::bigint) AS raw_state_size,
pg_size_pretty(pg_total_relation_size('resources')) AS resources_table,
pg_size_pretty(pg_total_relation_size('resource_dependencies')) AS deps_table
FROM states s JOIN state_versions sv ON sv.id = s.current_version_id
WHERE s.name='big-state'"
The normalized representation is competitive with the raw JSON in size, but indexed: arbitrary WHERE address LIKE 'module.primary_herd.random_string.%'-style queries are sub-millisecond instead of streaming gigabytes through jq.
Compare to the flat-state baseline
Two equivalents you might reach for in a vanilla Terraform setup, both for a "show me every resource of type X" query:
| Approach | What it does | Cost at 100k resources | |
|---|---|---|---|
| `terraform state list \ | grep 'random_id\.'` | Acquires the workspace lock, downloads the full state, deserializes it, prints every resource address, then greps. | Seconds to minutes; serializes against any concurrent operation. |
| `jq '.resources[] \ | select(.type=="random_id")' terraform.tfstate` | Streams the entire JSON blob through jq. | Hundreds of MB through CPU; many seconds. |
kl query "SELECT … WHERE type='random_id'" | One indexed SQL query against the running database. | Tens of milliseconds; no lock. |
This is the headline. Inventory, dependency, and compliance questions stop being "schedule a job to dump state to S3 every 6 hours and ingest into something else" and start being one-line queries against live data.
Known quirks
- **
terraform destroyat largesizetakes about as long as the correspondingapply.** Same per-resource overhead in reverse. If you abort an apply mid-flight and need a clean slate,TRUNCATEon the schema is faster thandestroy. - **Apply at
size=50000will use several GB of memory in Terraform itself**, mostly for the in-process graph. Kilolock's server-side footprint is modest by comparison. - If an apply is killed mid-flight, the lock survives. Use
terraform force-unlock <id>(works as of v0); the lock id is visible viakl query "SELECT lock_id, who, created FROM state_locks".
Reference: the topology
random_pet.deployment_name ← deployment identifier (root)
└── random_id.deployment_id ← depends on deployment_name
└── null_resource.deployment_marker
module "primary_herd" (size = var.size)
└── random_id.leader ← anchor for the herd
├── random_id.tag[count=size] ← fan-out (size edges)
└── random_string.label[count=size] ← fan-out (size edges)
module "shadow_herd" (size = 1)
└── random_id.leader ← seeded from primary_herd.leader
null_resource.summary ← references both herds