Scheduling Automated Boundary Validation Jobs
Designation data changes: Historic England schedules new monuments, local authorities revise conservation area boundaries, and your own feature dataset grows every field season. A validation run is only trustworthy if it reflects the current state of both, which means re-running it on a schedule rather than once at submission. This guide, part of the compliance reporting and statutory boundary validation workflow, wraps the validation logic in a nightly job — driven by a systemd timer or an Airflow DAG — that is idempotent, writes an immutable audit log, and alerts a human only when it detects a new violation.
Context & When to Use
Schedule validation once a dataset is live and multiple people touch it. For a single self-contained database on one server, a systemd timer running a Python entry point is the least-moving-parts option — no scheduler daemon to maintain, journal integration for logs, and precise OnCalendar control. Reach for an Airflow DAG only when validation is one task in a larger pipeline (ingest designations, validate, export, notify) with dependencies and retries worth modelling. Whichever you use, the two properties that matter are idempotency (a re-run of the same night must not create duplicate alerts or corrupt the log) and change-detection (alert on the delta, not the full violation set, or reviewers drown in noise and stop reading).
Implementation
systemd service and timer
The service runs the job as a one-shot; the timer schedules it. Persisting missed runs (Persistent=true) means a server that was down at 02:00 still validates when it comes back.
# /etc/systemd/system/boundary-validation.service
[Unit]
Description=Nightly statutory boundary validation
After=postgresql.service
[Service]
Type=oneshot
User=heritage
WorkingDirectory=/opt/heritage-gis
ExecStart=/opt/heritage-gis/.venv/bin/python -m validation.nightly
Environment=PGCONN=postgresql://heritage@localhost/archaeology_db
# /etc/systemd/system/boundary-validation.timer
[Unit]
Description=Run boundary validation nightly at 02:00
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
Enable and inspect it:
# systemd 255
sudo systemctl daemon-reload
sudo systemctl enable --now boundary-validation.timer
systemctl list-timers boundary-validation.timer # confirm NEXT / LEFT
journalctl -u boundary-validation.service -n 50 # read last run's output
The idempotent job with audit log and change detection
The audit log is append-only: one row per run recording inputs, counts, and the source currency, so any historical run is explainable. Idempotency comes from a per-day advisory lock — if last night’s run already committed, tonight’s re-trigger exits cleanly instead of double-alerting.
# requirements.txt
# psycopg==3.2.1
import os
import sys
import uuid
import logging
import psycopg
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("nightly")
AUDIT_DDL = """
CREATE TABLE IF NOT EXISTS compliance.validation_audit (
run_id UUID PRIMARY KEY,
run_date DATE NOT NULL,
features_tested INTEGER NOT NULL,
violations_found INTEGER NOT NULL,
new_violations INTEGER NOT NULL,
designation_src TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (run_date)
);
"""
# Advisory lock key derived from the calendar day makes the run idempotent per night.
LOCK_KEY = 0x5A_1D_0000 # arbitrary namespace constant
def run_nightly(conn_str: str) -> int:
run_id = str(uuid.uuid4())
with psycopg.connect(conn_str, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(AUDIT_DDL)
# Serialise concurrent triggers for the same day.
cur.execute("SELECT pg_try_advisory_xact_lock(%s)", (LOCK_KEY,))
if not cur.fetchone()[0]:
log.warning("another validation run holds the lock; exiting")
return 0
# Skip if today already recorded — the idempotency guarantee.
cur.execute("SELECT run_id FROM compliance.validation_audit "
"WHERE run_date = CURRENT_DATE")
if cur.fetchone():
log.info("validation already recorded for today; nothing to do")
return 0
# 1. Run the validation (reuses the section's VALIDATE_SQL).
cur.execute(VALIDATE_SQL, {"run_id": run_id, "buffer": 50})
found = cur.rowcount
cur.execute("SELECT count(*) FROM features")
tested = cur.fetchone()[0]
# 2. Change detection: which violations are new vs the previous run?
cur.execute("""
SELECT count(*) FROM (
SELECT feature_id, list_entry, rule
FROM compliance.boundary_violations WHERE run_id = %(new)s
EXCEPT
SELECT feature_id, list_entry, rule
FROM compliance.boundary_violations
WHERE run_id = (
SELECT run_id FROM compliance.validation_audit
ORDER BY run_date DESC LIMIT 1)
) delta
""", {"new": run_id})
new_count = cur.fetchone()[0]
cur.execute("""
INSERT INTO compliance.validation_audit
(run_id, run_date, features_tested, violations_found,
new_violations, designation_src)
VALUES (%s, CURRENT_DATE, %s, %s, %s,
'Historic England NHLE 2026-06')
""", (run_id, tested, found, new_count))
conn.commit()
log.info("run %s: %d tested, %d violations, %d new", run_id, tested, found, new_count)
if new_count > 0:
alert(run_id, new_count)
return new_count
def alert(run_id: str, new_count: int) -> None:
"""Non-zero exit and a message the timer surfaces via journald / email-on-failure."""
msg = f"ALERT: {new_count} new boundary violation(s) in run {run_id}"
log.error(msg)
# Wire to your channel: SMTP, Slack webhook, or systemd OnFailure= mail unit.
sys.stderr.write(msg + "\n")
if __name__ == "__main__":
new = run_nightly(os.environ["PGCONN"])
sys.exit(2 if new else 0) # exit 2 lets OnFailure= escalate real alerts
The VALIDATE_SQL statement is the one defined in validating features against scheduled monument boundaries; import it rather than duplicating. When a run finds new violations you can chain the submission package export to produce a fresh deliverable automatically.
Verification
Trigger the job manually and confirm the audit row, the idempotent no-op on a second run, and the exit code.
# systemd 255 — run the unit now, outside the schedule
sudo systemctl start boundary-validation.service
journalctl -u boundary-validation.service -n 20 --no-pager
-- PostgreSQL 16 / PostGIS 3.4
SELECT run_date, features_tested, violations_found, new_violations, designation_src
FROM compliance.validation_audit
ORDER BY run_date DESC LIMIT 3;
Expected: exactly one audit row for today; a second immediate systemctl start logs validation already recorded for today; nothing to do and adds no row (idempotency holds). A run with new violations exits 2; a clean run exits 0. Confirm the timer is armed with systemctl list-timers, which should show a NEXT of tomorrow 02:00.
Common Errors & Fixes
psycopg.errors.UniqueViolation: duplicate key value violates unique constraint "validation_audit_run_date_key"— two runs raced for the same day and both passed the existence check before either committed. Thepg_try_advisory_xact_lockserialises them; ensure the lock is taken before the existenceSELECT, all inside one transaction.Failed with result 'exit-code'insystemctl status— the job exited2by design because it found new violations. This is not a crash. Route it with anOnFailure=mail unit rather than treating it as a fault, or reserve non-zero exits strictly for real errors and alert from withinalert().- Timer never fires after reboot — the timer was started but not enabled, so it did not persist. Run
sudo systemctl enable boundary-validation.timer(not juststart) and verify withsystemctl is-enabled boundary-validation.timerreturningenabled.
Related
- Compliance Reporting and Statutory Boundary Validation — the section overview this scheduled job belongs to.
- Validating Features Against Scheduled Monument Boundaries — the validation query this job runs on a schedule.
- Exporting Planning Authority Submission Packages — chain package export when a run detects new violations.
- Automating Heritage Constraint Search Reports — regenerate constraint reports as designations change.
- Query Optimization for Large Excavation Datasets — keep the nightly validation query fast as feature volumes grow.