Repository navigation
ADR 0079 Fast Sandbox Lifecycle
Accepted (2026-11-03). Amends ADR 0076 (the pool, the claim and the sweep) and the demo history of ADR 0068; builds on the batched ingestion of ADR 0078.
A load test of the public demo host (2 vCPU, tests/load/vps/ladder.sh, scenario sandbox-visitor, all visitors arriving within 180 s) stayed within the thresholds up to 50 visitors, but the sandbox life cycle degraded first: at 30 visitors "Try the demo" (POST /identity/demo/session) had a p95 of 264 ms and the logout 260 ms, at 50 visitors 1.23 s and 3.1 s (average 2.6 s), while the host's CPU averaged only 57 %. Short bursts, not sustained load.
Profiling the claim and the release on the load test stack (tests/load, two pinned cores; py-spy on Identity, Fleet, Scheduler, Notifications and Telemetry; the new --lifecycle mode of tools/repo-checks/scripts/demo_sandbox_cost.py) showed:
- The pool refill is the biggest CPU consumer of a burst. Every claim asks for a refill, and a refill simulated a week for three mowers and ran about 6,700 states through Telemetry's ingestion: about 2.6 s of Telemetry CPU per sandbox on a desktop core (a third the simulation, the rest the write rules and the rows), about twice that on the VPS. The batching of ADR 0078 had already removed the per-state transactions; what was left is Python work per state. With 40 visitors in 60 s Telemetry ran at half a core for the whole minute, its event loop blocked for most of each batch, so every mower page and live view of the visitors already inside waited behind it. And still the refill kept up for only 16 of the 40 claims; the other 24 got sandboxes made on the spot without history.
- Making a sandbox on the spot was serialised. The account was created inside the claim lock, so a burst queued behind each other's account creation (OpenFGA writes), and then each made eight HTTP calls one after the other (three mowers, three schedules, the preferences read and written), each with a new HTTP client.
- Unbounded fresh sandboxes overload the other services. Without the lock, 40 visitors within 10 s asked Fleet, the Scheduler and OpenFGA for 38 accounts at once; OpenFGA checks queued past their timeout and were denied (fail closed), so the Scheduler answered "mower not found" for a mower Fleet had just made, and six claims failed.
-
The logout is cheap but not free.
POST /identity/session/logoutonly revokes the session (the purge runs later in the sweep); in Identity's single process 40 logouts at once still queue: 10 to 15 ms each on a desktop core while a pool refill ran beside them (the request handling, then a find and an update per session with a savepoint each, the audit row). On the VPS, in the scenario where every visitor leaves within the same second, this is the 2.6 s average. - The release took two sweep intervals. A logged out sandbox was marked at the next sweep (up to 60 s), Fleet's purge stopped the mowers, and the other services were purged at the sweep after the 30 s settle time, so another 60 s later.
-
A fresh service refuses a burst of first tokens.
mowdeck_auth.JwksCache.key_forstarted one JWKS fetch and every other lookup during that fetch found neither a key nor the right to fetch (the cooldown), so it answered "unknown signing key" (401 or the socket's 4401). A freshly started service after a deploy or a reset meets exactly that with the first visitors' live views.
Every sandbox of a day gets the same past days: the jobs start at the same hour each past day, in the same three gardens, and nothing of them depends on the mower but its ids. Telemetry (telemetry.application.demo_history.DemoHistory, behind the unchanged POST /telemetry/demo/history) therefore simulates a garden's days once per UTC day, for a template mower of a template account nobody can sign in to (fixed UUIDv5 ids from the garden options, the number of days and the date), through the normal batched ingestion. Every mower that asks for that garden on that day gets a copy of the template's rows with its own ids: Tx.copy_ingested, one INSERT ... SELECT ... ON CONFLICT DO NOTHING per table (history, positions with the thinning mark, the latest row whose JSON state is rewritten to name the mower and account, the health and day totals). A copy is exactly what the ingestion would have stored for the mower itself (tested on the memory store and on PostgreSQL).
- The template rows belong to an account without users, Fleet mowers or directory entry: no query of a visitor reaches them; retention and thinning treat them like any mower's.
- Making a day's template drops those of the seven days before. Which templates are complete is remembered in memory only; after a restart a template is made again (its leftovers dropped first), so an interrupted one is never copied.
- A mower that already has a state gets nothing, as the ingestion would refuse the older states; a mower of another account is refused (
AccountMismatchError). - The blade hours threshold event of the template's history is not repeated per copy (a week does not reach the 50 h default).
The shared demo's seed job uses the same endpoint and gets the same history.
-
DEMO_SANDBOX_POOLdefaults to 8 (was 2). A resting pool sandbox costs only its rows (ADR 0076) and a refill now takes about a second, so a burst of eight is served from the pool at once and the refill keeps up with about one visitor a second. It still refills one sandbox at a time. - The claim decision stays serialised (the cap holds), but making a sandbox on the spot is not: the claim reserves a place in memory that counts to the cap until the sandbox's row exists, then makes it outside the lock. At most
MOWDECK_IDENTITY_DEMO_SANDBOX_MAKE_PARALLEL(3) are made at once; a visitor waiting for a turn takes a sandbox the refill made meanwhile instead (it then has history too). - The provisioner keeps one HTTP client with its connections and runs what does not depend on each other side by side: the first mower alone (it makes the account's default site in Fleet), the other two together, then the three schedules with the preferences read, then the preferences write: four round trips instead of eight. A failing call fails the provisioning only after the others ended.
- Revoking a login session family is one
UPDATE(Table.update_where) instead of a find and an update with a savepoint per session; the sweep's revocation of a marked sandbox's sessions too. - The sweep runs 5 s after a logout (so a burst of logouts is swept once) and, after Fleet's purge, when the settle time is over instead of at the next interval. A logged out sandbox is gone about 45 s later instead of up to two minutes. The logout itself still only ends the session: the purge in every service stays in the background, retried until complete.
JwksCache.key_for, when a key id is unknown and a fetch is running (the lock is held), waits for that fetch and looks again instead of refusing the token. No extra fetch: random key ids during a fetch wait once and are refused afterwards, and the cooldown still limits fetches.
| Variable | Default | Meaning |
|---|---|---|
DEMO_SANDBOX_POOL (MOWDECK_IDENTITY_DEMO_SANDBOX_POOL) |
8 (was 2) |
Ready sandboxes |
MOWDECK_IDENTITY_DEMO_SANDBOX_MAKE_PARALLEL |
3 |
Sandboxes made on the spot at once |
demo/demo.env needs no new key. A demo.env that sets DEMO_SANDBOX_POOL=2 explicitly keeps the old pool; remove the line (or raise it) to get the new default.
The load test stack of tests/load (two pinned cores of a desktop host, the demo's settings), main (f44400e) against this change, demo_sandbox_cost.py --lifecycle: N visitors click "Try the demo" within the ramp from their own addresses, each opens the mower list, one mower, its state and /identity/me, then all log out at the same moment. Times measured at the client after the connection was open. A VPS core is slower; expect the demo host to show the same shape at higher numbers.
| 40 visitors | within 60 s, before | within 60 s, after | within 10 s, before | within 10 s, after |
|---|---|---|---|---|
| Claim median / p95 / max | 511 / 1092 / 2214 ms | 43 / 157 / 245 ms | 8940 / 13715 / 13760 ms | 3285 / 6918 / 7239 ms |
| Claims served by the pool (with history) | 16 of 40 | 40 of 40 | about 3 of 40 | 15 of 40 |
| Failed claims | 0 | 0 | 0 | 0 (6 without the bound on sandboxes made at once) |
| First view median / p95 | 337 / 517 ms | 362 / 431 ms | 615 / 1428 ms | 514 / 697 ms |
| Stack CPU while claiming (Telemetry), % of a core | 152 (49) | 105 (14) | 199 (42) | 198 (14) |
| Every sandbox deleted after the logouts | 120 s | 53 s | 107 s | 54 s |
The logout, measured inside the stack's network straight at Identity (40 sandboxes claimed, then 40 logouts at once): median / p95 / max 356 / 651 / 665 ms before, 211 / 367 / 381 ms after. (Measured from the workstation through Docker Desktop's port forwarding the same burst varied between 240 and 650 ms in both versions.)
The scenario sandbox-visitor of the load test (LOAD_VUS=40 LOAD_RAMP=180s LOAD_DURATION=2m LOAD_HOLD=60: the visitors arrive within three minutes, each starts a mower and holds the live view, all log out at the end, k6 inside the stack's network):
| 40 sandbox visitors | Before | After |
|---|---|---|
| API p95 | 1.30 s (threshold crossed) | 172 ms |
| "Try the demo" p95 / max | 6.09 s / 9.08 s | 98 / 160 ms |
| Logout p95 | 376 ms | 292 ms |
GET /telemetry/mowers/{id}/state p95 |
2.0 s | 213 ms |
| First live state p95 | 662 ms | 107 ms |
| Failed requests | 0.16 % | 0 % |
| Stack CPU average (Telemetry) | 157 % (40 %) | 93 % (15 %) |
With the cap raised for the run (DEMO_SANDBOX_MAX=60), 60 such visitors: "Try the demo" p95 271 ms, logout p95 602 ms, API p95 582 ms (just above the 500 ms threshold), 0.08 % failed requests, 99.5 % of the live views with states, the stack at 133 % on average and at its 200 % in peaks: beyond 40 the limit is again the watched mowers' telemetry (ADR 0078), not the life cycle.
- Bursts of visitors are served from the pool with history; the pool refill no longer blocks Telemetry, so the visitors already inside keep fast mower pages.
- Telemetry keeps one template mower per garden per day (about 7,000 rows each); the first pool fill of a UTC day pays the simulation once.
- A sandbox made on the spot still has no history (a burst beyond pool and refill), and a visitor may wait a few seconds for a turn instead of failing.
- Tests: Telemetry (
test_telemetry_demo_history.py: a copy equals the direct ingestion on memory and PostgreSQL, the thinning mark, idempotence, a new day's template, a restart, another account), Identity (test_identity_demo_sandbox_bursts.py: parallel creation under the cap, the bound and a waiting visitor taking a refill, a failed creation giving its place back, the sweep after a logout and after the settle time; the provisioner's parallel calls on one client;update_whereon both stores),mowdeck-auth(a burst on a fresh cache, random key ids during a fetch).
- Delete asynchronously on logout: it already was; the logout only ends the session. What remained was the per-request cost in a burst, cut by the one-statement revocation.
- Cloning whole template accounts in every service (ADR 0076's rejected alternative): Telemetry's history is where the cost was; mowers, schedules and preferences are a handful of rows through the normal APIs.
- Caching the simulated states in memory and ingesting them per sandbox: saves the simulation (a third) but not the ingestion, and holds thousands of state objects per garden.
- Fewer history days during a burst: cheaper, but the visitor of a burst would see less than the others, and the copy makes it unnecessary.
- History after the claim, in the background: Telemetry drops states older than the first live one (ADR 0076).
- Several Identity worker processes: the pool, the cap and the sweep hold state in one process; a second process would need them in the database first.
Mirrored from docs/adr/0079-fast-sandbox-lifecycle.md at commit ea8b3955b591ac0393dfcfda93ecacdeb6b8f1c3. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit ea8b3955b591ac0393dfcfda93ecacdeb6b8f1c3. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- ADR 0049: Manual driving over the Mammotion cloud
- ADR 0050: Passkeys and the app login hand-off
- ADR 0051: Sign in with GitHub
- ADR 0052: Sign in with Apple
- ADR 0053: Retention and downsampling of the position track
- ADR 0054: Aerial photo under the mower map
- ADR 0055: Deploy maintenance window
- ADR 0056: Camera photo in push notifications for errors and theft alarms
- ADR 0057: Step-up re-authentication for critical actions
- ADR 0058: Theft tracking mode
- ADR 0059: iOS widgets and the mowing Live Activity
- ADR 0060: Apple Watch app
- ADR 0061: Mowing angle rotation
- ADR 0062: Setup tour for new users
- ADR 0063: Matter server (mowers in Apple Home and Google Home)
- ADR 0064: Personal access tokens and the public API documentation
- ADR 0065: Security center and new-login alerts
- ADR 0066: Cloud provider plugins and adapters for more vendors
- ADR 0067: Error code lexicon, provider independent
- ADR 0068: Public demo mode
- ADR 0069: Provider conformance kit and provider package template
- ADR 0070: Virtual zones for mowers without a vendor map
- ADR 0071: Capability matrix generated from the adapters
- ADR 0072: API explorer in the web app, for the public demo
- ADR 0073: Guided tour with coach marks
- ADR 0074: NATS authentication and per-service subject permissions
- ADR 0075: Chat control as an optional assistant add-on
- ADR 0076: Demo sandboxes, one account per visitor
- ADR 0077: Liveness, readiness and routing
- ADR 0078: Batched state ingestion
- ADR 0079: Fast demo sandbox claim and release
-
Development
- Getting started
- Service template
- Shared libraries
- Cloud providers: adding a vendor
- Adding a provider: template and conformance kit
- Adding an error lexicon for a vendor
- Design: identity and authorization
- Design: fleet, telemetry, realtime
- Design: web app
- Web app
- Home Assistant integration
- Camera source bridge protocol
- Fuzzing and property-based tests
- CI and pull request scope
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- Vendor library updates
- Billing operations
- Telemetry: retention and position downsampling
- iPhone and Android apps
- Passkeys
- Sign in with GitHub
- Aerial photo under the map
- Sign in with Apple
- Theft tracking mode
- Matter server
- Chat control (assistant add-on)
- Source code offer (AGPL-3.0 section 13)
- Public demo
- Load testing
- Spikes
-
Guides
- Guides
- ESP32 bridge: purchase and setup
- Signing in with a passkey
- Signing in with GitHub
- Error codes in plain words
- Aerial photo under the map
- Signing in with Apple
- Confirming critical actions
- Your security page
- iPhone widgets and the mowing Live Activity
- Apple Watch
- The setup tour
- Apple Home and Google Home (Matter)
- Chat control
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)