Repository navigation
ADR 0053 Position Retention And Downsampling
Accepted (2026-10-27). Extends ADR 0017.
mower_positions is the largest table of Telemetry: a row for every changed position of a moving mower (every 1 to 20 s), a row when the RTK fix changes, and since the signal heatmaps (ADR 0017, migration 0007) also rows while the mower stands. ADR 0017 deleted everything older than 90 days, which bounds the table but throws away the history the heatmaps and the track are for: a season of coverage, the signal map over a year. Keeping everything grows without bound and slows the heatmap scans.
Readers of the table:
- the heatmaps (
GET /mowers/{id}/heatmap, layers coverage, rtk, wifi, mnet; default the last 7 days, any range up to the query limit), which join consecutive blades-on positions into swaths when at mostMOWDECK_TELEMETRY_HEATMAP_JOIN_SECONDS/_METRESapart; - the position track (
GET /mowers/{id}/positions,tracks.positions.query, the map trail and the coverage of spot mowing); - the history chart (
date_binjoins the last position of each bin).
Not readers: the health totals and mower_health_daily are computed on ingest and never read positions again; they are untouched. mower_history is already change-based (a row per change, one per five minutes for an unchanged state) and mower_snapshots holds one row per mower and kind; neither grows with the heartbeat rate.
Three age bands for positions.
- Younger than
MOWDECK_TELEMETRY_POSITIONS_DETAIL_DAYS(default 90, at least 7): every row, never touched. - Older: downsampled by the rules below.
- Older than
MOWDECK_TELEMETRY_RETENTION_POSITIONS_DAYS(default 730,0keeps them forever, otherwise at least the detail days): deleted by the existing batch delete.
What survives downsampling. Per mower, a row is kept when it is
- the first row of its bin, with bins of
MOWDECK_TELEMETRY_POSITIONS_THIN_ACTIVE_SECONDS(30) while the blades run or the mower moved more than 1 m since the previous row, and of..._THIN_IDLE_SECONDS(300) otherwise (both divide 300; bins are anchored at 2000-01-01 UTC like the history bins), or - an edge: next to a gap of more than 120 s (a session start or end, a silence), next to a change of
blades_on(job start and stop, docking, transfers) or of the RTK fix, or - a turn: the track bends by more than 25 degrees at the row (steps under 0.3 m are noise), so lane ends and corners keep their shape, or
- observed at the same instant as a history row with an error code.
Everything else is deleted. The same rules exist twice, as a pure function (telemetry.domain.thinning.keep_set, used by the in-memory store) and as one SQL statement per slice; a test runs both on random tracks and compares the survivors.
Heatmaps on thinned rows. Kept rows of one swath are at most two active bins apart, which is more than the 30 s join limit. Rather than keeping blades-on detail longer, the heatmap joins a pair of points with the wider limits max(join_seconds, 2 * active + 5) (65 s) and the metres scaled by the same factor (about 22 m) when the older point is older than the detail window. Recent data is joined exactly as before. The straight joins are exact on straight lanes (the kept points are collinear) and the turn rule keeps the corners; the test compares the covered area before and after thinning (within 12 %) and shows that without the wider join it would fall apart into discs. The signal and RTK layers bin points independently and only see fewer, evenly spread samples (one per 30 s moving, one per 5 min standing), which the median and the fixed share tolerate.
How it runs. The maintenance loop already running the retention (MOWDECK_TELEMETRY_RETENTION_INTERVAL_SECONDS, hourly, advisory lock on one replica) calls thin_once after the deletes. It does nothing outside the nightly window MOWDECK_TELEMETRY_THINNING_WINDOW_START_HOUR to ..._END_HOUR (default 1 to 5, local hour in MOWDECK_TELEMETRY_MAINTENANCE_TIMEZONE, default UTC; equal hours mean always; the window may wrap midnight). The window is evaluated on the local wall-clock hour of the aware timestamp, so DST needs no special case: a spring night has one real hour fewer inside a window that spans the jump, an autumn night one more, and a window inside the skipped hour is never entered; the work is resumable, so a short night only delays it. A round works slice by slice (at most 6 hours of one mower per statement, so a statement touches a few thousand rows), stops after MOWDECK_TELEMETRY_THINNING_BUDGET_SECONDS (900) or when the window closes, and releases the lock.
Idempotent and safe with concurrent writes. Table mower_positions_thinning (mower_id, thinned_until) (migration 0011) is the watermark: a slice runs once, then the watermark moves past it; a crash between the delete and the watermark repeats the slice, which deletes nothing more. Slice ends lie on a 5-minute grid so bins never straddle a boundary, and the statement reads 10 minutes of context on both sides to see the neighbours of its edge rows. Slices end at least the detail window in the past, ingest only inserts at the present, so the writer never conflicts with the deletes; the delete by (mower_id, observed_at) uses the primary key. Rows that arrive later behind the watermark (clock skew, a replay) are not thinned but are deleted by the age limit. No index is added: the slice scans use ix_mower_positions_mower_time, the age delete the BRIN index. The statements use no VACUUM; autovacuum reclaims the space for reuse, and a one-time VACUUM (ANALYZE) after the first big run is documented in the ops guide (never VACUUM FULL).
Rule for other consumers of raw positions (theft tracking). Anything that needs full-resolution rows must read only data younger than the detail window, or store its own copy. Thinning never touches a row younger than POSITIONS_DETAIL_DAYS, and that value is at least 7 days, so a tracking session shorter than the detail window cannot be thinned while it is read. A feature that flags a longer session must raise the detail window or copy the rows.
Other tables. mower_history (30 days) and command_timeline (30 days) keep their age limits; they are change-based and small. mower_snapshots, mower_health, mower_health_daily and blade_changes are one row per mower, kind or day and stay until the mower is removed.
Metrics and logs. mowdeck_telemetry_retention_rows_deleted_total{table}, mowdeck_telemetry_position_thinning_rows_scanned_total, mowdeck_telemetry_position_thinning_rows_deleted_total and the histogram mowdeck_telemetry_retention_round_seconds{kind=delete|thin}; one info line per round that did work (mowers, slices, scanned, deleted, seconds, complete).
- The default for positions changes from "delete after 90 days" to "thin after 90 days, delete after 730 days". Installations that want the old behaviour set
MOWDECK_TELEMETRY_RETENTION_POSITIONS_DAYS=90. - An old position track has gaps of up to 30 s while mowing (straight between kept points) and 5 minutes while standing; exact trails of old jobs are no longer available. Hot data, the live map and the last 90 days are unchanged.
- The first run after an upgrade walks every slice from the mower's oldest row to the cutoff, one statement per 6 hours; it takes several nights on a large table and shows up in the metrics. Later nights process one day.
- Row counts shrink roughly by the ratio of the stored interval to the bin width; the ops guide has the read-only SQL to measure it on a live database.
- Thinning is tuned by the settings; changing the bin widths affects only slices not yet processed.
Mirrored from docs/adr/0053-position-retention-and-downsampling.md at commit f2b638806535ae0bd3f33714e176f58bf05f35f2. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit f2b638806535ae0bd3f33714e176f58bf05f35f2. 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
- Development
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- 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
- Source code offer (AGPL-3.0 section 13)
- Spikes
- Guides
- Original plan (German)