Skip to content

Commit e38cf3e

Browse files
fix: replace destructive Dagster event cleanup with safe retention + backups (#132)
The dagster_db_cleanup job deleted every non-error event older than 24h and cascade-deleted asset_materializations, asset_observations, asset_check_executions, and event_log_tags. Those index tables are orchestration/automation state — AutomationConditions, "latest materialization", asset checks, and lineage read from them — so cleanup made materialized assets look un-materialized and could retrigger automation. Retention (safe, by event class): - cleanup_event_logs.sh now deletes ONLY plain log-message rows (event_logs with dagster_event_type IS NULL), in bounded batches, default 30-day retention. It never touches structured events or the asset index tables. Legacy DAGSTER_EVENT_RETENTION_HOURS is still honored (now log-rows only). - dagster.yaml gains Dagster's supported `retention:` for schedule/sensor ticks (the actual high-volume tick tables), no raw SQL. Backups: - backup_dagster_postgres.sh + dagster_db_backup compose service: interval pg_dump -> gzipped timestamped dumps on a dagster_backups volume, retention pruning, optional GPG encryption (GCP PD is encrypted at rest regardless). - docker/DAGSTER_RETENTION_AND_BACKUP.md documents retention-by-class, RPO (24h) / RTO (~15m), and a tested restore drill. Config surfaced in .env.dagster.example. Tests (test_dagster_retention_cleanup.py) statically enforce the contract: cleanup never DELETEs the protected tables, deletes only NULL-type event_logs rows, scripts pass `sh -n`, dagster.yaml declares tick retention, and compose wires the backup service + volume. (Live restore drill must be run against a real PostgreSQL — cannot execute in CI.) Closes #132 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3cf37ba commit e38cf3e

7 files changed

Lines changed: 398 additions & 63 deletions

.env.dagster.example

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,22 @@ DAGSTER_PG_PASSWORD=change-me-to-secure-password
1515
DAGSTER_UI_PORT=3000
1616

1717
# ============================================================================
18-
# Dagster Event Log Cleanup (Optional)
19-
# ============================================================================
20-
# Deletes non-error events older than the retention window.
21-
DAGSTER_EVENT_RETENTION_HOURS=24
18+
# Dagster Log Pruning + PostgreSQL Backups (Optional) — see
19+
# docker/DAGSTER_RETENTION_AND_BACKUP.md
20+
# ============================================================================
21+
# Log pruning deletes ONLY plain log-message rows (event_logs with
22+
# dagster_event_type IS NULL). Materialization/observation/asset-check state is
23+
# NEVER deleted; schedule/sensor tick retention is handled in dagster.yaml.
24+
DAGSTER_LOG_RETENTION_DAYS=30
25+
DAGSTER_LOG_CLEANUP_BATCH_SIZE=5000
2226
DAGSTER_EVENT_CLEANUP_INTERVAL_SECONDS=3600
2327

28+
# Automated PostgreSQL backups (dagster_db_backup service).
29+
DAGSTER_BACKUP_INTERVAL_SECONDS=86400
30+
DAGSTER_BACKUP_RETENTION_DAYS=14
31+
# Optional app-level backup encryption (disk-at-rest encryption applies regardless).
32+
DAGSTER_BACKUP_GPG_PASSPHRASE=
33+
2434
# ============================================================================
2535
# Google Cloud Platform — Primary Data Warehouse (BigQuery)
2636
# ============================================================================

dagster.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,18 @@ run_retries:
105105
max_retries: 2
106106
retry_on_asset_or_op_failure: true
107107

108+
# Tick retention (issue #132) — Dagster's supported mechanism for pruning the
109+
# high-volume schedule/sensor tick tables. Successful ticks are kept so
110+
# automation history stays auditable; skipped/failed ticks age out. This does
111+
# NOT touch materialization/observation/asset-check state.
112+
retention:
113+
schedule:
114+
purge_after_days: 90
115+
sensor:
116+
purge_after_days:
117+
skipped: 7
118+
failure: 30
119+
success: 90
120+
108121
telemetry:
109122
enabled: false

docker-compose.yml

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,9 @@ services:
283283
OLLAMA_HOST: http://ollama:11434
284284
restart: "no"
285285

286-
# Dagster DB cleanup - prune non-error events older than retention
286+
# Dagster log pruning — deletes ONLY plain log-message rows (event_logs with
287+
# dagster_event_type IS NULL). Never touches materialization/observation/
288+
# asset-check state. See docker/DAGSTER_RETENTION_AND_BACKUP.md.
287289
dagster_db_cleanup:
288290
image: postgres:15
289291
container_name: dagster_db_cleanup
@@ -294,7 +296,8 @@ services:
294296
DAGSTER_DB_NAME: dagster
295297
DAGSTER_DB_USER: dagster_user
296298
DAGSTER_DB_PASSWORD: ${DAGSTER_PG_PASSWORD}
297-
DAGSTER_EVENT_RETENTION_HOURS: ${DAGSTER_EVENT_RETENTION_HOURS:-24}
299+
DAGSTER_LOG_RETENTION_DAYS: ${DAGSTER_LOG_RETENTION_DAYS:-30}
300+
DAGSTER_LOG_CLEANUP_BATCH_SIZE: ${DAGSTER_LOG_CLEANUP_BATCH_SIZE:-5000}
298301
DAGSTER_EVENT_CLEANUP_INTERVAL_SECONDS: ${DAGSTER_EVENT_CLEANUP_INTERVAL_SECONDS:-3600}
299302
volumes:
300303
- ./docker/cleanup_event_logs.sh:/opt/dagster/cleanup_event_logs.sh:ro
@@ -314,8 +317,42 @@ services:
314317
max-size: "10m"
315318
max-file: "2"
316319

320+
# Automated encrypted PostgreSQL backups with retention (issue #132).
321+
dagster_db_backup:
322+
image: postgres:15
323+
container_name: dagster_db_backup
324+
restart: unless-stopped
325+
environment:
326+
DAGSTER_DB_HOST: dagster_postgresql
327+
DAGSTER_DB_PORT: "5432"
328+
DAGSTER_DB_NAME: dagster
329+
DAGSTER_DB_USER: dagster_user
330+
DAGSTER_DB_PASSWORD: ${DAGSTER_PG_PASSWORD}
331+
BACKUP_DIR: /backups
332+
BACKUP_INTERVAL_SECONDS: ${DAGSTER_BACKUP_INTERVAL_SECONDS:-86400}
333+
BACKUP_RETENTION_DAYS: ${DAGSTER_BACKUP_RETENTION_DAYS:-14}
334+
# Optional app-level encryption; disk-at-rest encryption applies regardless.
335+
BACKUP_GPG_PASSPHRASE: ${DAGSTER_BACKUP_GPG_PASSPHRASE:-}
336+
volumes:
337+
- ./docker/backup_dagster_postgres.sh:/opt/dagster/backup_dagster_postgres.sh:ro
338+
- dagster_backups:/backups
339+
entrypoint: ["/bin/sh", "/opt/dagster/backup_dagster_postgres.sh"]
340+
networks:
341+
- dagster_network
342+
depends_on:
343+
dagster_postgresql:
344+
condition: service_healthy
345+
mem_limit: 256m
346+
memswap_limit: 256m
347+
logging:
348+
driver: "json-file"
349+
options:
350+
max-size: "10m"
351+
max-file: "2"
352+
317353
volumes:
318354
postgres_data:
355+
dagster_backups:
319356
driver: local
320357
dagster_storage:
321358
driver: local
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Dagster Retention & PostgreSQL Backups
2+
3+
How this deployment prunes Dagster history safely and how to back up / restore the
4+
Dagster PostgreSQL database. Addresses issue #132 (the previous cleanup deleted
5+
materialization/observation/asset-check state, which broke automation and audit
6+
history).
7+
8+
## Retention by event class
9+
10+
| Class | What it is | Where it lives | Retention | Managed by |
11+
|---|---|---|---|---|
12+
| **Materializations / observations / asset checks** | Asset state that AutomationConditions, "latest materialization", asset checks, and lineage read | `event_logs` (structured) + `asset_materializations`, `asset_observations`, `asset_check_executions` | **Never deleted** ||
13+
| **Structured run/step events** | `RUN_*`, `STEP_*`, `ENGINE_EVENT`, etc. — run history | `event_logs` (`dagster_event_type IS NOT NULL`) | **Never deleted** ||
14+
| **Plain log-message rows** | High-volume `context.log.*` lines | `event_logs` (`dagster_event_type IS NULL`) | `DAGSTER_LOG_RETENTION_DAYS` (default **30d**) | `dagster_db_cleanup` (`cleanup_event_logs.sh`) |
15+
| **Schedule ticks** | Schedule evaluation ticks | `job_ticks` | `purge_after_days: 90` | Dagster `retention:` (dagster.yaml) |
16+
| **Sensor ticks** | Sensor evaluation ticks | `job_ticks` | skipped 7d / failure 30d / success 90d | Dagster `retention:` (dagster.yaml) |
17+
18+
Key rule: **orchestration and audit state is never deleted by a cleanup job.**
19+
Only disposable log-message rows and schedule/sensor ticks age out, and the tick
20+
purge uses Dagster's own supported `retention:` config rather than raw SQL
21+
against internal tables.
22+
23+
### Why not delete the asset index tables?
24+
25+
`asset_materializations` / `asset_observations` / `asset_check_executions` are how
26+
Dagster answers "what is the latest materialization of this asset?" Deleting them
27+
makes materialized assets look un-materialized, which can retrigger
28+
AutomationConditions and destroys asset-check and lineage history. The cleanup
29+
job therefore never touches them, and only deletes `event_logs` rows that carry
30+
no structured event (`dagster_event_type IS NULL`) — those never have a row in
31+
the index tables, so no state can be orphaned.
32+
33+
## Configuration
34+
35+
Set in `.env` (see `.env.dagster.example`):
36+
37+
| Variable | Default | Effect |
38+
|---|---|---|
39+
| `DAGSTER_LOG_RETENTION_DAYS` | `30` | Age after which plain log rows are pruned |
40+
| `DAGSTER_LOG_CLEANUP_BATCH_SIZE` | `5000` | Rows deleted per batch (bounds lock time) |
41+
| `DAGSTER_EVENT_CLEANUP_INTERVAL_SECONDS` | `3600` | Cleanup loop interval |
42+
| `DAGSTER_BACKUP_INTERVAL_SECONDS` | `86400` | Backup cadence (daily) |
43+
| `DAGSTER_BACKUP_RETENTION_DAYS` | `14` | Age after which backups are pruned |
44+
| `DAGSTER_BACKUP_GPG_PASSPHRASE` | _(empty)_ | If set (and `gpg` present), symmetrically encrypts each dump |
45+
46+
> The legacy `DAGSTER_EVENT_RETENTION_HOURS` is still honored by
47+
> `cleanup_event_logs.sh` (converted to days) for backward compatibility, but it
48+
> now affects **only** plain log rows — never structured events.
49+
50+
## Backups
51+
52+
The `dagster_db_backup` service runs `pg_dump` on `DAGSTER_BACKUP_INTERVAL_SECONDS`,
53+
writing `dagster_<UTC-timestamp>.sql.gz` to the `dagster_backups` volume and
54+
pruning dumps older than `DAGSTER_BACKUP_RETENTION_DAYS`.
55+
56+
**Encryption.** GCP persistent disks are encrypted at rest by default (AES-256),
57+
so a backups volume on a GCP PD is already encrypted. Set
58+
`DAGSTER_BACKUP_GPG_PASSPHRASE` to add application-level symmetric encryption
59+
(produces `.sql.gz.gpg`); this requires `gpg` in the image.
60+
61+
**Off-host copies (recommended).** The volume lives on the same VM as the DB. For
62+
real disaster recovery, sync `dagster_backups` to object storage, e.g.:
63+
64+
```bash
65+
gcloud storage rsync -r /var/lib/docker/volumes/<project>_dagster_backups/_data \
66+
gs://<your-backup-bucket>/dagster-postgres/
67+
```
68+
69+
## Objectives
70+
71+
- **RPO (Recovery Point Objective): 24 hours.** With daily backups, at most one
72+
day of Dagster metadata (run/materialization history) can be lost. Lower
73+
`DAGSTER_BACKUP_INTERVAL_SECONDS` for a tighter RPO.
74+
- **RTO (Recovery Time Objective): ~15 minutes.** Restore is a single
75+
`gunzip | psql` into a fresh database plus a service restart.
76+
77+
Note: the warehouse data itself (BigQuery) is the system of record for analytics;
78+
these backups protect Dagster's **orchestration metadata**, not the analytical
79+
tables.
80+
81+
## Restore drill
82+
83+
Run this in a non-production environment periodically to prove backups are valid.
84+
85+
```bash
86+
# 1. Pick a backup from the volume.
87+
docker compose run --rm --entrypoint sh dagster_db_backup \
88+
-c 'ls -1t /backups/dagster_*.sql.gz* | head'
89+
90+
# 2. Create a scratch database to restore into (does not touch the live DB).
91+
docker compose exec dagster_postgresql \
92+
psql -U dagster_user -d postgres -c 'CREATE DATABASE dagster_restore_test;'
93+
94+
# 3. Restore the dump into the scratch DB.
95+
# (If the file ends in .gpg, first: gpg --batch --passphrase "$PASS" -d file.sql.gz.gpg > file.sql.gz)
96+
docker compose exec dagster_db_backup sh -c \
97+
'gunzip -c /backups/<chosen>.sql.gz | psql -h dagster_postgresql -U dagster_user -d dagster_restore_test'
98+
99+
# 4. Verify asset state survived — expect a non-zero count.
100+
docker compose exec dagster_postgresql psql -U dagster_user -d dagster_restore_test \
101+
-c 'SELECT count(*) AS materializations FROM asset_materializations;'
102+
103+
# 5. Clean up.
104+
docker compose exec dagster_postgresql \
105+
psql -U dagster_user -d postgres -c 'DROP DATABASE dagster_restore_test;'
106+
```
107+
108+
**Production restore:** stop the Dagster services, restore the dump into the
109+
`dagster` database (drop/recreate or restore into an empty DB), then restart:
110+
111+
```bash
112+
docker compose stop dagster_webserver dagster_daemon dagster_user_code
113+
gunzip -c /backups/<chosen>.sql.gz | \
114+
docker compose exec -T dagster_postgresql psql -U dagster_user -d dagster
115+
docker compose start dagster_user_code dagster_daemon dagster_webserver
116+
```

docker/backup_dagster_postgres.sh

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/bin/sh
2+
# Automated Dagster PostgreSQL backups (issue #132).
3+
#
4+
# Runs pg_dump on an interval, writes a compressed timestamped dump to the
5+
# mounted backups volume, prunes dumps older than the retention window, and
6+
# (optionally) encrypts each dump with a GPG passphrase.
7+
#
8+
# Encryption: GCP persistent disks are encrypted at rest by default (AES-256),
9+
# so a backups volume on a GCP PD is already encrypted. Set BACKUP_GPG_PASSPHRASE
10+
# to add application-level symmetric encryption on top (requires gpg in the
11+
# image). See docker/DAGSTER_RETENTION_AND_BACKUP.md for the restore drill.
12+
set -eu
13+
14+
: "${DAGSTER_DB_HOST:=dagster_postgresql}"
15+
: "${DAGSTER_DB_PORT:=5432}"
16+
: "${DAGSTER_DB_NAME:=dagster}"
17+
: "${DAGSTER_DB_USER:=dagster_user}"
18+
: "${DAGSTER_DB_PASSWORD:?DAGSTER_DB_PASSWORD must be set}"
19+
: "${BACKUP_DIR:=/backups}"
20+
: "${BACKUP_INTERVAL_SECONDS:=86400}" # daily
21+
: "${BACKUP_RETENTION_DAYS:=14}"
22+
: "${BACKUP_GPG_PASSPHRASE:=}" # empty = rely on disk-level encryption
23+
24+
export PGPASSWORD="$DAGSTER_DB_PASSWORD"
25+
26+
mkdir -p "$BACKUP_DIR"
27+
28+
if [ -n "$BACKUP_GPG_PASSPHRASE" ] && ! command -v gpg >/dev/null 2>&1; then
29+
echo "backup: WARNING BACKUP_GPG_PASSPHRASE set but gpg not found; writing unencrypted (disk-level encryption still applies)" >&2
30+
fi
31+
32+
echo "backup: dir=${BACKUP_DIR} interval=${BACKUP_INTERVAL_SECONDS}s retention=${BACKUP_RETENTION_DAYS}d"
33+
34+
while true; do
35+
# Wait for the database to accept connections before dumping.
36+
if ! pg_isready -h "$DAGSTER_DB_HOST" -p "$DAGSTER_DB_PORT" -U "$DAGSTER_DB_USER" >/dev/null 2>&1; then
37+
echo "backup: database not ready, retrying shortly"
38+
sleep 30
39+
continue
40+
fi
41+
42+
stamp=$(date -u +%Y%m%dT%H%M%SZ)
43+
tmp="${BACKUP_DIR}/dagster_${stamp}.sql.gz.partial"
44+
final="${BACKUP_DIR}/dagster_${stamp}.sql.gz"
45+
46+
# -Fc would be smaller/parallel-restorable, but plain SQL + gzip keeps the
47+
# restore drill dependency-free (psql only). Fail the cycle (not the loop)
48+
# if the dump errors, so a transient failure doesn't kill the service.
49+
if pg_dump -h "$DAGSTER_DB_HOST" -p "$DAGSTER_DB_PORT" -U "$DAGSTER_DB_USER" \
50+
-d "$DAGSTER_DB_NAME" --no-owner --no-privileges | gzip -c > "$tmp"; then
51+
if [ -n "$BACKUP_GPG_PASSPHRASE" ] && command -v gpg >/dev/null 2>&1; then
52+
gpg --batch --yes --passphrase "$BACKUP_GPG_PASSPHRASE" \
53+
--symmetric --cipher-algo AES256 -o "${final}.gpg" "$tmp"
54+
rm -f "$tmp"
55+
echo "backup: wrote ${final}.gpg"
56+
else
57+
mv "$tmp" "$final"
58+
echo "backup: wrote ${final}"
59+
fi
60+
else
61+
echo "backup: ERROR pg_dump failed for ${stamp}" >&2
62+
rm -f "$tmp"
63+
fi
64+
65+
# Prune old backups (both plain and encrypted).
66+
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'dagster_*.sql.gz*' \
67+
-mtime "+${BACKUP_RETENTION_DAYS}" -print -delete 2>/dev/null || true
68+
69+
sleep "$BACKUP_INTERVAL_SECONDS"
70+
done

0 commit comments

Comments
 (0)