Repository navigation
ADR 0078 Batched State Ingestion
Accepted (2026-11-02). Follow-up of ADR 0076 (its measurements) and of the load test (Load testing, "Ingestion cost"). Changes how Telemetry and Notifications consume telemetry.mower.*; the write rules of ADR 0017 and ADR 0053 stay as they are.
Every state a mower reports is consumed by Telemetry (latest row, history, positions, health totals, virtual zones), Notifications (the rules), the Scheduler (busy check), Vision (automatic analysis and its view), the camera relay and Realtime (the live fan-out). A simulated mower that mows reports once a second, a real one every few seconds while it works and with every heartbeat (ADR 0040) otherwise.
ADR 0076 measured that a demo sandbox watched by its visitor, one mower mowing, costs about 5 % of a core, and that most of it is not the simulator but the processing of its states. Profiling the consumers (py-spy, 20 watched sandboxes, about 27 states a second) showed where it went:
-
One transaction per state and consumer. Telemetry ran about nine statements per state (directory, latest row
FOR UPDATE, history, position, latest upsert, healthFOR UPDATE, health upsert, day row, day upsert) plus the pool's ping,BEGINandCOMMIT; Notifications checked three connections out of the pool per state (directory, geofence, the rule transaction), each with a ping; the Scheduler one more transaction. Postgres committed about 80 transactions a second for 27 states. -
Building SQL per message. Every upsert built its statement anew (
insert(...).values(...),on_conflict_do_update(set_={... excluded ...})): SQLAlchemy recomputed theexcludedcolumn collection and the cache key each time. In Telemetry and the Scheduler this was the largest single cost, more than the database round trips. - Writes nobody needs. The Scheduler upserted the mower's activity and position for every state; it needs the activity when it changes and the position for the weather.
- Acknowledgements of live views. Each state reached seven consumers, each acknowledged it: eight NATS messages per state, although three of them (Realtime's fan-out, Vision's and the camera relay's latest state) gain nothing from a redelivery.
EventBus.subscribe_batch (JetStream and in-memory) hands a handler all messages that arrived while its previous batch ran, up to max_batch, after lingering max_wait_seconds for more. The handler answers one outcome per message (acknowledge, redeliver, drop). Messages are settled only after the handler returned, so the delivery guarantee is unchanged: what was not acknowledged comes again (at least once). Batches of one subscription never overlap, so delivery order is kept. Raising from the handler redelivers the whole batch. Unsubscribing (shutdown) stops new deliveries and lets the worker handle and settle what it already received, within the drain timeout; anything left unsettled is redelivered by the server. JetStream's max_ack_pending (1000) bounds the worker's queue.
Ingest.record_states reads the directory accounts, the latest rows (without the stored state document) and the health totals of the batch's mowers once, locked in mower id order (two replicas never deadlock), applies the write rules state by state in arrival order to these rows in memory and writes one statement per table: every history and position row, and per mower the last latest row, the health totals and the day totals. The result is exactly what the same states stored one at a time would produce; tests compare both for the memory store and PostgreSQL. A state that names another account than the directory or the stored rows is left out and dropped alone; the others are stored. A database failure stores nothing and redelivers the batch; a redelivered state changes nothing (the existing redelivery rules: an equal observed_at writes no history or position, advance counts no interval twice). Blade threshold events are published and virtual zones evaluated after the commit, in order (the zones of all the batch's mowers read at once).
MOWDECK_TELEMETRY_INGEST_BATCH_MAX (64) and MOWDECK_TELEMETRY_INGEST_BATCH_WAIT_SECONDS (0.2) configure it; the stored latest state lags the stream by at most the wait, which no client notices (live states come over Realtime). The demo's history import stores 200 states per transaction through the same path.
Watcher.on_states reads each mower's directory account, geofence key and watch once per batch (one statement for account and geofence key, the watch FOR UPDATE in mower id order), runs the rules state by state, writes each changed watch once and fans the derived events out after the commit, in order: the existing guarantee (at most once per derived event, never twice for a redelivered state) stays. The parsed geofence is kept per mower and stored version (observed_at) instead of being read and parsed for every state. Settings MOWDECK_NOTIFICATIONS_STATE_BATCH_MAX and _STATE_BATCH_WAIT_SECONDS.
- Statements built once (Telemetry, Notifications, Scheduler): the upserts on the ingestion paths are module level statements executed with a parameter dict.
- The Scheduler writes the status when it matters: an activity change at once, an unchanged activity at most every 30 s or after a move of 100 m. The follow-up of a started run and the weather fallback position tolerate that lag; an older report is never written (as before).
-
Live views without acknowledgements:
subscribe(..., acknowledge=False)creates a JetStream consumer withAckPolicy.NONE, refused for durable and queue consumers. Realtime's fan-out of states, Vision's view and the camera relay's latest state use it; every consumer that stores or acts keeps explicit acknowledgements. -
Demo knob:
MOWDECK_SIM_STATE_PERIOD_SECONDS(Device-Gateway, simulated mowers only; demoDEMO_SIM_STATE_PERIOD_SECONDS, default 0 = unchanged) lets an operator report periodic states less often than once a second; changes are still reported at once.
See Load testing, "Ingestion cost" (method: tools/repo-checks/scripts/demo_sandbox_cost.py, alternating runs of main and this change on the same host).
- Fewer, larger transactions: Postgres commits per state drop by about five times in Telemetry and Notifications, the Scheduler writes about a tenth of what it wrote; the rows written per state stay the same (each state of a mowing mower is one more history or position row, one latest row and one health row: the stored data does not change).
- A batch holds its mowers' latest and watch rows locked a little longer; replicas lock in mower id order and cannot deadlock.
- Telemetry's stored latest state and Notifications' rules see a state up to the batch wait (0.2 s) later.
MOWDECK_..._BATCH_WAIT_SECONDS=0batches only what queued up while the previous batch was written. - Realtime, Vision's view and the camera relay no longer get a failed state again; the next state follows within seconds.
- New consumers of a high-rate subject should prefer a batch subscription when they write per message, and
acknowledge=Falsewhen they only keep or forward the latest value.
- Coalescing per mower (write only the last state of a window): fewer rows, but history and position rows are the product (tracks, heatmaps, health); dropping intermediate states would change the data of real mowers. Batching keeps every row.
-
Skipping the latest-row update when nothing relevant changed: the latest row carries
observed_at, the heartbeat and the full state the API serves; skipping it would serve stale positions. -
A memory-backed
TELEMETRYstream: cheaper for NATS, but states published while a consumer is down would not survive a NATS restart, and changing the storage of an existing stream needs it recreated. Not worth it next to the acknowledgement savings. - A lower state rate for real mowers: the rate is the vendor's (and the adaptive interval of the Mammotion adapter); only the simulator's rate is MowDeck's, hence the demo knob.
Mirrored from docs/adr/0078-batched-state-ingestion.md at commit 9a8ea60d02ea1eb5fe3e6bdaa849ccd3d85b6f41. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 9a8ea60d02ea1eb5fe3e6bdaa849ccd3d85b6f41. 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
- ADR 0080: GitHub Codespaces dev container with the full Compose stack
- ADR 0081: Explicit consent before camera pictures or text go to a cloud AI provider
- ADR 0082: Deleting your own account inside the app
-
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
- Security assurance case
- Fuzzing and property-based tests
- CI and pull request scope
- Reproducible builds
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Internal TLS
- 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)