Skip to content

Archive Compaction โ€‹

PurposeDaily consolidation of the archive's per-flight Parquet index files into one file per day, so Athena/Glue partition projection isn't scanning thousands of tiny files per partition indefinitely
Run frequencyDaily, scheduled by ofelia
Reads/writesAWS S3, plus a local index-cache volume shared with archive-processor (see Local Index Cache) โ€” no Redis, no RabbitMQ

Archive compaction architecture

How it works โ€‹

The archive processor writes one small Parquet index row per flight to index/year={YYYY}/month={MM}/day={DD}/{uuid}.parquet alongside the flight's own flights/{YYYY}/{MM}/{DD}/{uuid}.json.gz object (see archive-processor/README.md and specs/data-dictionary.yaml's archive_parquet_index record). Each run of this job:

  1. Reads the last successfully compacted date โ€” the watermark โ€” from _compaction_state/watermark.json in S3 (a sibling prefix to flights/ and index/, never nested inside either, so Glue's partition projection template never mistakes it for a partition file). A missing watermark (first run ever) is treated as "nothing compacted yet."
  2. Walks forward one date at a time, from the day after the watermark up to the day before yesterday (UTC, not yesterday โ€” this absorbs flight_ttl_seconds archival delay and any lag from the archive processor's local s3.db offline-fallback queue draining late). A single run can therefore clear a multi-day backlog once whatever stalled it is fixed, rather than crawling forward one day per scheduled run.
  3. Before compacting each date, verifies parity: every flight object under that date's flights/ prefix must have a matching Parquet index row under its index/ prefix, matched by the UUID embedded in each key. A mismatch โ€” a flight archived with no index row ever landing for it โ€” stops the loop at that date; nothing later is attempted either, and the watermark stays exactly where it was. This is deliberately different from the late-straggler case below: a late straggler is a file this run simply hasn't seen yet and will pick up on its own once seen; a parity mismatch means a date was actually checked and is still missing a row, which won't fix itself by moving on to the next date. Consecutive runs blocked on the same date are counted (mismatch_runs, reset once the blocked date changes or the mismatch clears) so a compactor stuck for days is visible in MQTT stats rather than only a repeated ERROR log line.
  4. For a date that passes the parity check: lists every object under that day's index/year=/month=/day=/ prefix (via S3, always โ€” parity checking and listing never consult the local cache), filtering out any file already produced by a previous compaction run (identified by a compacted- filename prefix โ€” a per-flight file is always a bare UUID, so this can never collide with one), reads and merges the remaining per-flight files into a single Arrow table, and writes it as one new compacted-{uuid}.parquet file under the same partition. Each per-flight file's content is read from the local index cache when present, falling back to a real S3 GetObject only when it's missing โ€” see Local Index Cache.
  5. Deletes only the source files that were actually read into that output โ€” never a file that failed to read, and never a file that arrived under the prefix after the initial listing (a late straggler). Both cases are left in place: an extra small file in the partition, still queryable on its own via Glue's partition projection (which reads every file under a partition as one table), with no duplication risk. The watermark only advances past a date once its compaction step actually completes. A file's local index-cache copy (if any) is removed the moment its S3 counterpart is confirmed deleted โ€” see Local Index Cache. Transient per-key S3 errors (InternalError, SlowDown, ServiceUnavailable) and whole-request failures are retried up to 3 attempts total with a 1s/2s backoff; other errors (e.g. AccessDenied) are not retried.

A file that's left behind โ€” whether a late straggler or one whose delete still failed after retries, after being included in a compacted output โ€” is not picked up by a later run targeting a different date. This is an accepted, self-healing edge case (see specs/data-dictionary.yaml), not a bug: it costs one extra small file in that day's partition, not incorrect query results, except in the narrow case of a post-inclusion delete failure, where the row is present twice until someone manually removes the lingering source file โ€” logged clearly via files_delete_failed when it happens.

Local Index Cache โ€‹

archive-processor and archive-compaction run on the same host (docker-compose.archive.yaml) and both bind-mount the same host directory at /app/index-cache. archive-processor writes a local copy of each per-flight Parquet index row there right after uploading it to S3; this job reads from that local copy instead of downloading the row again, issuing a real S3 GetObject only when the local copy is unexpectedly missing (a partial/failed write on archive-processor's side, or a row written before this cache existed) โ€” see archive-processor/README.md for the write side.

The local path mirrors the S3 key's own layout, minus the index/ prefix: /app/index-cache/year={YYYY}/month={MM}/day={DD}/{uuid}.parquet โ€” so a multi-day catch-up run (see step 2 above) naturally finds each backlogged date's local files under its own subdirectory, with no extra bookkeeping for how many days are outstanding.

Only the read path (step 4 above) consults the local cache. check_date_parity (step 3) and the post-compaction delete (step 5) are both S3-only, exactly as before โ€” the local cache is purely a faster way to get bytes this job would otherwise download, never a second source of truth about what exists. A file's local copy is removed the moment its S3 counterpart is confirmed deleted, so the shared volume only ever holds the not-yet-compacted backlog rather than growing without bound; if the S3 delete itself fails (see files_delete_failed above), the local copy is left in place along with the lingering S3 source.

Configuration โ€‹

Reads its configuration from environment variables via shared/config.py's load_config("mqtt", "s3"), interpolated by Compose from this host's .env (written by scripts/install.sh). No Redis, no RabbitMQ -- see the table at the top of this page.

VariableRequiredDefaultDescription
S3_BUCKETโœ…โ€”S3 bucket the archive processor writes flights/index rows to
AWS_DEFAULT_REGIONโœ…โ€”boto3's own variable name -- no credentials are ever passed in code, so every client picks up the default credential chain. Shared with archive-processor
AWS_ACCESS_KEY_IDโœ…โ€”boto3's own variable name. docker-compose.archive.yaml maps this from the .env key ARCHIVE_COMPACTION_AWS_ACCESS_KEY_ID -- a credential pair distinct from archive-processor's, so this job runs under its own least-privilege IAM identity (see AWS Setup). The Ofelia scheduled-run label maps the same key
AWS_SECRET_ACCESS_KEYโœ…โ€”boto3's own variable name. Mapped from the .env key ARCHIVE_COMPACTION_AWS_SECRET_ACCESS_KEY
MQTT_HOSTโŒโ€”Leave unset to disable MQTT entirely
MQTT_PORTโŒ1883
MQTT_USERNAMEโŒโ€”Optional MQTT auth; leave unset for an anonymous broker
MQTT_PASSWORDโŒโ€”
LOG_LEVELโŒinfo"debug" for verbose output

AWS Setup โ€‹

No IAM identity for this job exists yet in a fresh AWS account โ€” nor the bucket, Glue database/table, or Athena workgroup. All of it is created by the one-shot aws-setup container, which deploys a CloudFormation stack from specs/aws/cloudformation.yaml; scripts/install.sh runs it when you install the archive role. See docs/aws-configuration.md.

This job's identity is deliberately separate from archive-processor's: it needs s3:DeleteObject on index/* and bucket-level s3:ListBucket that archive-processor doesn't, and it has no access to flights/* object content at all โ€” it only lists flights/* keys for the parity check, never reads them. It also gets s3:GetObject/s3:PutObject on _compaction_state/* for the watermark. That split is only actually reachable now that the archive host accepts two credential pairs (the ARCHIVE_PROCESSOR_AWS_* / ARCHIVE_COMPACTION_AWS_* keys); before, both services shared one credential and one of them was always over- or under-privileged.

MQTT โ€‹

Published once, at the end of a run, to SkyFollower/archive-compaction/statistic/{name} (all retained):

Topic suffixValueFormat
files_compactede.g. 142Integer as string โ€” per-flight files merged into this run's compacted output(s), summed across every date compacted this run
files_delete_failede.g. 0Integer as string โ€” files that were included in a compacted output but whose delete still failed after retries, and therefore still linger as duplicates, summed across every date compacted this run
days_compactede.g. 1Integer as string โ€” number of date partitions successfully compacted this run (more than one during catch-up after a gap)
last_compacted_datee.g. 2026-07-23, or NoneThe watermark after this run โ€” the most recent date whose partition has been fully compacted; None only on a fresh deployment before the first successful compaction
mismatch_datee.g. 2026-07-24, or NoneThe date this run stopped at due to a flight/index parity mismatch; None when the run wasn't stopped by one
mismatch_uuid_counte.g. 3, or 0Integer as string โ€” number of flight UUIDs missing their index row on mismatch_date; 0 in the healthy state
mismatch_uuidse.g. {"uuids": ["0198abcd-...", "0198abce-..."]}, or {"uuids": []}JSON object listing the flight UUIDs missing their index row on mismatch_date โ€” a starting point for manual investigation. Check local_index_queue_depth on the archive processor's own stats: nonzero means the row is likely still draining locally and will resolve on its own; zero means it's genuinely lost from the archive processor's perspective. Not a standalone HA sensor โ€” consumed only as mismatch_uuid_count's json_attributes_topic
mismatch_runse.g. 7, or 0Integer as string โ€” number of consecutive runs blocked on mismatch_date. Resets to 0 once that date compacts successfully, or to 1 if a different date becomes the blocker. A climbing number here, not just a repeated ERROR log line, is the signal that a mismatch needs a human rather than time
last_run_ate.g. 2026-07-25T04:50:03.123456+00:00ISO 8601 UTC
last_run_statusSuccess, Failure, or MismatchString โ€” Mismatch means the run completed without error but stopped early on a parity mismatch (see mismatch_date/mismatch_uuids/mismatch_runs); Failure means an actual exception (S3 error, etc.)

Home Assistant autodiscovery configs are also published (retained) to homeassistant/sensor/SkyFollower_archive_compaction_{name}/config for nine of the ten topics above โ€” mismatch_uuids has no standalone sensor; it's consumed only as mismatch_uuid_count's json_attributes_topic.

The process exit code also distinguishes the two non-success cases: 0 on Success, 2 on Mismatch, 1 on Failure. Both non-zero codes still read as "not clean" to ofelia, but anything inspecting exit status directly (rather than the MQTT stats) can tell "blocked on a parity mismatch" apart from "the job actually errored." A mismatch never exits 0 โ€” a compactor stuck for weeks must not look healthy to something watching exit status alone.

Deployment โ€‹

This job needs its own S3 credentials โ€” no Redis, no RabbitMQ โ€” so it's scheduled by a dedicated ofelia instance on the archive host (docker-compose.archive.yaml) rather than the core's ofelia (which only schedules Redis-facing data runners, and has no AWS credentials configured anywhere on that host today). Its own IAM identity is deliberately separate from archive-processor's, not a shared or widened one โ€” see AWS Setup above for exactly what it needs and why (Delete and bucket-level List, which archive-processor doesn't need; no access to flights/* content at all). See the ofelia service's labels in docker-compose.archive.yaml for the schedule.

Ofelia's scheduled run creates its own container directly through the Docker Engine API rather than reusing this file's archive-compaction service definition, so the shared index-cache mount (see Local Index Cache) is declared a second time as its own ofelia.job-run.archive-compaction.volume label, resolving to the same host directory as archive-processor's /app/index-cache mount. The manual docker compose run --rm archive-compaction path uses the service definition's own volumes: entry instead โ€” both paths land on the same host directory either way.