Repository navigation
ADR 0077 Liveness Readiness And Routing
Accepted (2026-11-01). Follow-up of the load test (Load testing, recommendation 1).
Every MowDeck service built on the service kit had two health endpoints: /healthz (the process runs) and /readyz (every readiness check passes: database, OpenFGA, NATS, the service's subscriptions, ...). The Compose healthcheck of every Python service asked /readyz, and so did, indirectly, everything that hangs on Docker health:
-
docker compose up --wait,deploy.shanddepends_on: condition: service_healthywait until a service is healthy; - Traefik's Docker provider routes only to healthy containers: an unhealthy container loses its routes, and a request for them gets 404 from the gateway.
The load test showed what that does under overload. With about 200 users clicking through the app on two cores, Telemetry was saturated: its readiness checks waited behind the requests for a pooled database connection and for OpenFGA and ran into their 2 s timeout. After four failed checks Docker marked the container unhealthy, Traefik dropped its routes, and every Telemetry request got 404 for about 40 s at a time (9.4 % failed requests in the run; the web app showed "not found"). The service was slow, not broken; with one replica, taking it out of routing only turned slowness into an outage, and a 404 even tells clients the wrong thing.
The same coupling existed in every Python service, in the service template and, with different consequences, in the Helm chart: Kubernetes probes /readyz for readiness (a failing pod leaves its Service) and /healthz for liveness and startup.
-
Keep the healthcheck on
/readyz, with longer timeouts and more retries. Lowers the risk, does not remove it: a long enough overload, or a short outage of a dependency, still takes the only replica out of routing. -
Healthcheck on
/healthz(liveness only). Load and dependencies never affect routing. But Compose would call a service healthy as soon as its process serves, before its first successful database or OpenFGA round trip:up --waitanddepends_onwould lose what they wait for. - Route without Docker health (a Traefik file provider, or Traefik's own HTTP health check of the backends). Possible, but it splits the configuration (the labels stay the routing source for Compose) and Traefik's health check would have the same problem with a saturated service unless tuned per service.
- A third endpoint that means "has started": it runs the readiness checks until they pass once, and from then on answers like liveness. Healthcheck and Kubernetes startup probe use it; readiness stays what it was, for the readiness probe, operators and metrics.
Option 4, plus the changes that make readiness itself robust.
The kit serves three endpoints with three meanings:
| Endpoint | Meaning | Used by |
|---|---|---|
/healthz |
liveness: the process serves requests; never touches a dependency | Kubernetes liveness probe |
/startupz |
started: 503 until every readiness check passed once (app.state.started), then 200 like /healthz for the rest of the process's life |
Compose healthcheck, the images' HEALTHCHECK, Kubernetes startup probe |
/readyz |
readiness now: every check, 503 with the failing ones and while draining | Kubernetes readiness probe, operators |
-
Compose: every Python service's healthcheck asks
/startupz(every 2 s while starting, every 30 s after; timeout 10 s, the request's own 8 s, 3 retries).up --wait,deploy.shanddepends_onkeep waiting for a ready service; afterwards only a hung process makes a container unhealthy. Traefik runs with--providers.docker.allowEmptyServices=true, so even then the routes stay and answer 503 (retry later) instead of 404. -
Dependency trouble is still visible, just no longer through routing: each service evaluates its checks every
MOWDECK_READINESS_INTERVAL_SECONDS(default 30, 0 turns it off), logsservice not readywith the failing checks andservice ready againon recovery, and exportsmowdeck_readiness_check_up{check}andmowdeck_service_started. A service whose database or OpenFGA is gone answers its requests with errors (503, or deny for authorization) by itself, which is what a client should see. -
Readiness does not measure load: the database check opens a connection of its own for each check (
NullPool) instead of waiting for one of the request pool, so a pool busy with requests is not a broken database. -
Kubernetes: startup probe
/startupz(every 2 s, up to 3 minutes), liveness/healthz(timeout 5 s, 6 misses = one minute, so a busy pod is not restarted), readiness/readyzevery 10 s, timeout 5 s (above the checks' 2 s), 3 failures before a pod leaves its Service. Readiness still removes a pod whose dependencies fail, which is right when other replicas can take over, but a short slow phase does not flap it. The web app's nginx has/healthzonly and keeps it for all three.
Alongside, as part of the same follow-up: the database pools are bounded and budgeted (MOWDECK_DATABASE_POOL_SIZE, MOWDECK_DATABASE_MAX_OVERFLOW, default 5 + 5, Telemetry 10 + 5; PostgreSQL max_connections 200 in Compose and the chart, checked by tools/repo-checks), and standard library log records keep their extra fields in the JSON log, so a denied authorization check names its cause.
- A saturated service stays routed. The reproduction (200 users clicking, two cores) no longer marks Telemetry unhealthy and has no 404 phases (numbers in Load testing).
-
up --waitbehaves as before: a service is healthy only after its dependencies answered once. - A dependency that fails after the start no longer turns a container unhealthy in Compose; whoever operates the stack watches the logs or the metric (or
/readyz). With one replica per service that is the better failure mode; with several replicas behind Kubernetes the readiness probe still steers traffic away from a pod whose dependencies fail. - A hung process is detected later than a failed readiness check used to be (up to about 90 s in Compose, one minute in Kubernetes). Docker does not restart unhealthy containers; Kubernetes does through liveness.
- New services get the model from the kit and the template;
tools/repo-checksfails when a Compose healthcheck, a snippet, a Dockerfile or the chart's probes ask the wrong endpoint.
Mirrored from docs/adr/0077-liveness-readiness-and-routing.md at commit 85963aa7891af1809fc26a9941dc7a1f10054a4d. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 85963aa7891af1809fc26a9941dc7a1f10054a4d. 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
-
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)