0.6.4: Truthful maintenance digest + scheduled jobs queue for the lock instead of skipping

The Telegram digest reported healthy weekly jobs as "never run yet": its
maintenance section still globbed for cron-era per-script log filenames
(clean-index-*.log etc.) that jobs run through pipeline_runner no longer
write. It now reads job_runs, the scheduler's own record, so it reports the
last success (age + summary snippet from that run's log), flags a newer
failed or lock-skipped attempt on the same line, and distinguishes "never
succeeded (last attempt: skipped, lock busy)" from genuinely never
scheduled. Also matched the clear-bad-genres snippet grep to the script's
current summary wording, and dropped the now-unused newest_log helper.

The DB also showed WHY two jobs had never run: on Sunday the 08:30
strip-mb-tags run took 10m19s while holding the shared pipeline lock, so
strip-watermark-art (08:35) and scrub-watermark-text (08:40) hit flock-style
instant skip and lost their only slot of the week -- the 08:40 job missed by
19 seconds. Scheduled runs now wait up to 30 minutes for the lock
(SCHEDULED_LOCK_WAIT_SECONDS) and only then record skipped_lock, so
fixed-time blocks queue instead of starving; manual "Run now" keeps the
instant skip since a person expects an immediate answer.

Tests: scheduled-run queueing, lock-wait timeout, and manual instant-skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-15 10:13:02 -06:00
parent 8ecff44811
commit 7a49e2d507
4 changed files with 167 additions and 64 deletions
+3 -3
View File
@@ -73,10 +73,10 @@ Optional, add these later if you want them:
Pull the prebuilt image onto your Docker host: Pull the prebuilt image onto your Docker host:
```bash ```bash
docker pull git.kretzer.club/andrew/alembic:0.6.3 docker pull git.kretzer.club/andrew/alembic:0.6.4
``` ```
That is the whole install. You do not need to download the source or build anything. The `0.6.3` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest. That is the whole install. You do not need to download the source or build anything. The `0.6.4` is the version; you can pin to it so nothing changes under you, or use `latest` to always get the newest.
(If you would rather build it yourself from source, you can, but you do not need to.) (If you would rather build it yourself from source, you can, but you do not need to.)
@@ -136,7 +136,7 @@ Create a file called `docker-compose.yml` on your server (put it wherever you ke
```yaml ```yaml
services: services:
alembic: alembic:
image: git.kretzer.club/andrew/alembic:0.6.3 image: git.kretzer.club/andrew/alembic:0.6.4
container_name: alembic container_name: alembic
ports: ports:
- "8420:8420" - "8420:8420"
+33 -8
View File
@@ -12,6 +12,15 @@ from app.settings import settings
# all-jobs-share-one-lock behavior exactly rather than over-engineering it. # all-jobs-share-one-lock behavior exactly rather than over-engineering it.
_lock = asyncio.Lock() _lock = asyncio.Lock()
# How long a SCHEDULED run waits for the lock before giving up (recorded as
# skipped_lock). The old flock -n instant-skip starves fixed-time schedules:
# the Sunday 08:30 strip-mb-tags run took 10m19s on 2026-07-12, so the 08:35
# and 08:40 jobs both hit the held lock and silently lost their only slot of
# the week. Scheduled jobs have nobody watching, so queueing (bounded) beats
# skipping; manual runs keep the instant skip because a person clicking "Run
# now" should get an immediate answer, not a silent 30-minute wait.
SCHEDULED_LOCK_WAIT_SECONDS = 1800.0
_ENV_PASSTHROUGH_KEYS = ("PATH", "HOME", "LANG", "LC_ALL", "TZ") _ENV_PASSTHROUGH_KEYS = ("PATH", "HOME", "LANG", "LC_ALL", "TZ")
@@ -94,18 +103,30 @@ async def _execute(
timeout: float | None, timeout: float | None,
use_lock: bool, use_lock: bool,
capture: bool, capture: bool,
lock_wait: float | None,
) -> tuple[JobRun, str]: ) -> tuple[JobRun, str]:
"""Shared core for run_job/run_job_capture: acquire the lock (unless """Shared core for run_job/run_job_capture: acquire the lock (unless
use_lock=False), record the run, exec the subprocess, and finalize. When use_lock=False), record the run, exec the subprocess, and finalize. When
capture=True, stdout+stderr is captured as text and returned; otherwise it capture=True, stdout+stderr is captured as text and returned; otherwise it
streams straight to the log file. Returns (JobRun, output_text) -- the text streams straight to the log file. Returns (JobRun, output_text) -- the text
is "" in the non-capture and skipped-lock cases.""" is "" in the non-capture and skipped-lock cases.
lock_wait: how long to wait for a held lock before recording
skipped_lock. None picks the policy default: SCHEDULED_LOCK_WAIT_SECONDS
for triggered_by="schedule", instant skip for everything else."""
started_at = time.time() started_at = time.time()
if lock_wait is None:
lock_wait = SCHEDULED_LOCK_WAIT_SECONDS if triggered_by == "schedule" else 0.0
acquired = False acquired = False
if use_lock: if use_lock:
if not await _try_acquire_nowait(): if not await _try_acquire_nowait():
return _record_run(job_key, started_at, triggered_by, finished_at=started_at, status="skipped_lock"), "" if lock_wait <= 0:
return _record_run(job_key, started_at, triggered_by, finished_at=started_at, status="skipped_lock"), ""
try:
await asyncio.wait_for(_lock.acquire(), timeout=lock_wait)
except asyncio.TimeoutError:
return _record_run(job_key, started_at, triggered_by, finished_at=time.time(), status="skipped_lock"), ""
acquired = True acquired = True
try: try:
@@ -170,18 +191,21 @@ async def run_job(
triggered_by: str = "schedule", triggered_by: str = "schedule",
timeout: float | None = None, timeout: float | None = None,
use_lock: bool = True, use_lock: bool = True,
lock_wait: float | None = None,
) -> JobRun: ) -> JobRun:
"""Run a pipeline command under the shared mutual-exclusion lock, """Run a pipeline command under the shared mutual-exclusion lock,
recording one job_runs row start-to-finish. If the lock is already recording one job_runs row start-to-finish. If the lock is already held,
held, records status='skipped_lock' immediately and returns without scheduled runs (triggered_by="schedule") wait up to
running anything -- the old flock -n behavior, now visible in the UI SCHEDULED_LOCK_WAIT_SECONDS for it before recording skipped_lock -- see
instead of silently skipping. that constant for why -- while manual/other runs record skipped_lock
immediately (the old flock -n behavior, visible in the UI instead of
silently skipping). Pass lock_wait to override either way.
use_lock=False runs the command WITHOUT taking the pipeline lock, for use_lock=False runs the command WITHOUT taking the pipeline lock, for
read-only jobs that never touch the library (e.g. the status report). read-only jobs that never touch the library (e.g. the status report).
Those must never be starved by a long-running write job, and running Those must never be starved by a long-running write job, and running
them concurrently is safe.""" them concurrently is safe."""
run, _ = await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=False) run, _ = await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=False, lock_wait=lock_wait)
return run return run
@@ -191,6 +215,7 @@ async def run_job_capture(
triggered_by: str = "manual", triggered_by: str = "manual",
timeout: float | None = None, timeout: float | None = None,
use_lock: bool = True, use_lock: bool = True,
lock_wait: float | None = None,
) -> tuple[JobRun, str]: ) -> tuple[JobRun, str]:
"""Like run_job(), but captures stdout+stderr as text and returns it """Like run_job(), but captures stdout+stderr as text and returns it
alongside the JobRun, instead of only writing it to the log file -- alongside the JobRun, instead of only writing it to the log file --
@@ -198,7 +223,7 @@ async def run_job_capture(
dedup_review_service's dry-run scan. The full output is still written dedup_review_service's dry-run scan. The full output is still written
to a log file afterward so job_runs.log_path works the same as any to a log file afterward so job_runs.log_path works the same as any
other job.""" other job."""
return await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=True) return await _execute(job_key, argv, triggered_by, timeout, use_lock, capture=True, lock_wait=lock_wait)
async def run_playlist(playlist_name: str, no_m3u: bool = False, triggered_by: str = "schedule") -> JobRun: async def run_playlist(playlist_name: str, no_m3u: bool = False, triggered_by: str = "schedule") -> JobRun:
+99 -51
View File
@@ -70,17 +70,6 @@ human_age() {
fi fi
} }
# Newest matching log → "<age_seconds>|<path>", empty if no match.
newest_log() {
local glob="$1"
local newest
newest=$(ls -1t $glob 2>/dev/null | head -1)
[[ -z "$newest" ]] && return
local mtime
mtime=$(stat -c %Y "$newest" 2>/dev/null) || return
echo "$((NOW_TS - mtime))|$newest"
}
# Per-playlist log status. # Per-playlist log status.
playlist_status() { playlist_status() {
local log="$1" local log="$1"
@@ -226,20 +215,15 @@ dedup_today_status() {
fi fi
# 3. Maintenance freshness # 3. Maintenance freshness
# Each weekly/monthly task: find its newest log and check age. # Read from the app's job_runs table (the scheduler's own record) instead of
# globbing for log files. Jobs run through pipeline_runner write their logs
# to logs/<job_key>/<timestamp>.log, so the old cron-era filename globs
# matched nothing for jobs that don't also write their own log, and healthy
# jobs were reported as "never run yet". The DB also distinguishes lock
# skips and failures, which a missing log file can't.
# Limits: weekly tasks should be <9 days old; daily <2; monthly <35. # Limits: weekly tasks should be <9 days old; daily <2; monthly <35.
section "Maintenance (last run)" section "Maintenance (last run)"
declare -A MAINT_GLOB=(
[strip-mb-tags]="${ALEMBIC_CONFIG_DIR:-/config}/logs/mb-strip-*.log"
[strip-watermark-art]="${ALEMBIC_CONFIG_DIR:-/config}/logs/strip-watermark-*.log"
[scrub-watermark-text]="${ALEMBIC_CONFIG_DIR:-/config}/logs/scrub-text-*.log"
[clean-sldl-index]="${ALEMBIC_CONFIG_DIR:-/config}/logs/clean-index-*.log"
[clear-bad-genres]="${ALEMBIC_CONFIG_DIR:-/config}/logs/clear-bad-genres-*.log"
[spotify-genre]="${ALEMBIC_CONFIG_DIR:-/config}/logs/spotify-genre-*.log"
[dedup-library]="${ALEMBIC_CONFIG_DIR:-/config}/logs/dedup-*.log"
[upgrade-mp3-to-flac]="${ALEMBIC_CONFIG_DIR:-/config}/logs/upgrade-mp3-*.log"
)
declare -A MAINT_LIMIT=( declare -A MAINT_LIMIT=(
[strip-mb-tags]=$((9*86400)) [strip-mb-tags]=$((9*86400))
[strip-watermark-art]=$((9*86400)) [strip-watermark-art]=$((9*86400))
@@ -250,42 +234,106 @@ dedup_today_status() {
[dedup-library]=$((2*86400)) [dedup-library]=$((2*86400))
[upgrade-mp3-to-flac]=$((35*86400)) [upgrade-mp3-to-flac]=$((35*86400))
) )
# Emits one line per task: task|latest_status|latest_age_s|success_age_s|success_log
# (ages are -1 when there is no such run). Read-only DB open; prints nothing
# if the DB is missing so every task falls through to "never run yet".
maint_rows=$(python3 - "${ALEMBIC_CONFIG_DIR:-/config}/alembic.db" <<'PYEOF'
import sqlite3
import sys
import time
# Digest label -> job_runs.job_key. The scheduled genre refresh records under
# genre:run (via genre_review_service) and dedup under dedup:scan (via
# dedup_review_service); everything else is its MAINTENANCE_JOBS key.
KEYS = [
("strip-mb-tags", "maintenance:strip_mb_tags"),
("strip-watermark-art", "maintenance:strip_watermark_art"),
("scrub-watermark-text", "maintenance:scrub_watermark_text"),
("clean-sldl-index", "maintenance:clean_sldl_index"),
("clear-bad-genres", "maintenance:clear_bad_genres"),
("spotify-genre", "genre:run"),
("dedup-library", "dedup:scan"),
("upgrade-mp3-to-flac", "maintenance:upgrade_mp3_to_flac"),
]
now = time.time()
try:
conn = sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True)
conn.execute("SELECT 1 FROM job_runs LIMIT 1")
except sqlite3.Error:
sys.exit(0)
for task, key in KEYS:
latest = conn.execute(
"SELECT status, started_at FROM job_runs WHERE job_key = ? ORDER BY started_at DESC LIMIT 1",
(key,),
).fetchone()
if latest is None:
print(f"{task}|none|-1|-1|")
continue
success = conn.execute(
"SELECT started_at, log_path FROM job_runs WHERE job_key = ? AND status = 'success' "
"ORDER BY started_at DESC LIMIT 1",
(key,),
).fetchone()
s_age = int(now - success[0]) if success else -1
s_log = (success[1] or "") if success else ""
print(f"{task}|{latest[0]}|{int(now - latest[1])}|{s_age}|{s_log}")
PYEOF
)
for task in strip-mb-tags strip-watermark-art scrub-watermark-text clean-sldl-index clear-bad-genres spotify-genre dedup-library upgrade-mp3-to-flac; do for task in strip-mb-tags strip-watermark-art scrub-watermark-text clean-sldl-index clear-bad-genres spotify-genre dedup-library upgrade-mp3-to-flac; do
res=$(newest_log "${MAINT_GLOB[$task]}") row=$(grep "^${task}|" <<< "$maint_rows" || true)
if [[ -z "$res" ]]; then IFS='|' read -r _ latest_status latest_age age_s log <<< "$row"
if [[ -z "$row" || "$latest_status" == "none" ]]; then
mark_skip "$(printf '%-22s never run yet' "$task")" mark_skip "$(printf '%-22s never run yet' "$task")"
continue continue
fi fi
age_s=${res%%|*} if [[ "$age_s" -lt 0 ]]; then
log=${res##*|} # attempted, but never once succeeded -- say what the last attempt did
case "$latest_status" in
skipped_lock) note="skipped, lock busy" ;;
running) note="running now" ;;
*) note="$latest_status" ;;
esac
mark_warn "$(printf '%-22s never succeeded (last attempt %s: %s)' "$task" "$(human_age "$latest_age")" "$note")"
continue
fi
age_h=$(human_age "$age_s") age_h=$(human_age "$age_s")
case "$task" in snip=""
spotify-genre) if [[ -n "$log" && -f "$log" ]]; then
snip=$(grep -oE "written: [0-9]+, unchanged: [0-9]+" "$log" | tail -1) ;; case "$task" in
clear-bad-genres) spotify-genre)
snip=$(grep -oE "(would-clear|cleared): [0-9]+ tracks" "$log" | tail -1) ;; snip=$(grep -oE "written: [0-9]+, unchanged: [0-9]+" "$log" | tail -1) ;;
clean-sldl-index) clear-bad-genres)
snip=$(grep -oE "[0-9]+ m3us, total [0-9]+ kept, [0-9]+ dropped" "$log" | tail -1) ;; snip=$(grep -oE "rewrote: [0-9]+ tracks, blanked: [0-9]+ tracks|(would-clear|cleared): [0-9]+ tracks" "$log" | tail -1) ;;
strip-watermark-art) clean-sldl-index)
snip=$(grep -oE "stripped from [0-9]+ tracks|no images shared" "$log" | tail -1) ;; snip=$(grep -oE "[0-9]+ m3us, total [0-9]+ kept, [0-9]+ dropped" "$log" | tail -1) ;;
scrub-watermark-text) strip-watermark-art)
snip=$(grep -oE "stripped [0-9]+ frame\(s\) across [0-9]+ file" "$log" | tail -1) ;; snip=$(grep -oE "stripped from [0-9]+ tracks|no images shared" "$log" | tail -1) ;;
strip-mb-tags) scrub-watermark-text)
snip=$(grep -oE "Done\. Log:" "$log" | head -1 | sed 's/Done\. Log:/done/') ;; snip=$(grep -oE "stripped [0-9]+ frame\(s\) across [0-9]+ file" "$log" | tail -1) ;;
dedup-library) strip-mb-tags)
snip=$(grep "=== Summary:" "$log" | tail -1 \ snip=$(grep -oE "Done\. Log:" "$log" | head -1 | sed 's/Done\. Log:/done/') ;;
| sed -E 's/.*=== Summary: //; s/ ===.*//; s/ \([^)]*\)//') ;; dedup-library)
upgrade-mp3-to-flac) snip=$(grep "=== Summary:" "$log" | tail -1 \
# The log's "to attempt" count is frozen at the last monthly run, so on | sed -E 's/.*=== Summary: //; s/ ===.*//; s/ \([^)]*\)//') ;;
# its own it looks stale against the live "Library by format" section. upgrade-mp3-to-flac)
# Pair it with the current format:mp3 count so the drop (upgrades that # The log's "to attempt" count is frozen at the last monthly run, so on
# have landed since) is visible instead of looking like a mismatch. # its own it looks stale against the live "Library by format" section.
attempted=$(grep -oE "[0-9]+ MP3 tracks to attempt" "$log" | grep -oE "^[0-9]+" | tail -1) # Pair it with the current format:mp3 count so the drop (upgrades that
mp3_now=$(beet ls 'format:mp3' 2>/dev/null | grep -c '^') # have landed since) is visible instead of looking like a mismatch.
snip="${attempted:-?} attempted · ${mp3_now} MP3 now" ;; attempted=$(grep -oE "[0-9]+ MP3 tracks to attempt" "$log" | grep -oE "^[0-9]+" | tail -1)
mp3_now=$(beet ls 'format:mp3' 2>/dev/null | grep -c '^')
snip="${attempted:-?} attempted · ${mp3_now} MP3 now" ;;
esac
fi
# Age/snippet describe the last SUCCESS; if a newer attempt failed or was
# lock-skipped, say so rather than hiding it behind the healthy line.
case "$latest_status" in
failed) snip="${snip:+$snip, }latest attempt failed" ;;
skipped_lock) snip="${snip:+$snip, }latest attempt skipped, lock busy" ;;
esac esac
line=$(printf '%-22s %-9s %s' "$task" "$age_h" "${snip:-}") line=$(printf '%-22s %-9s %s' "$task" "$age_h" "${snip:-}")
if (( age_s > MAINT_LIMIT[$task] )); then if (( age_s > MAINT_LIMIT[$task] )) || [[ "$latest_status" == "failed" ]]; then
mark_warn "$line" mark_warn "$line"
else else
mark_ok "$line" mark_ok "$line"
+32 -2
View File
@@ -25,11 +25,41 @@ def test_run_job_failure():
assert run.exit_code == 1 assert run.exit_code == 1
def test_lock_skip_when_held(): def test_manual_run_skips_immediately_when_lock_held():
async def scenario(): async def scenario():
await pr._lock.acquire() await pr._lock.acquire()
try: try:
return await pr.run_job("test:locked", ["true"], use_lock=True) return await pr.run_job("test:locked", ["true"], use_lock=True, triggered_by="manual")
finally:
pr._lock.release()
run = _run(scenario())
assert run.status == "skipped_lock"
def test_scheduled_run_waits_for_lock_by_default(monkeypatch):
# scheduled runs must QUEUE behind a held lock (bounded), not skip --
# instant-skip starved the Sunday maintenance block (see
# SCHEDULED_LOCK_WAIT_SECONDS)
monkeypatch.setattr(pr, "SCHEDULED_LOCK_WAIT_SECONDS", 5.0)
async def scenario():
await pr._lock.acquire()
task = asyncio.ensure_future(pr.run_job("test:queued", ["true"], triggered_by="schedule"))
await asyncio.sleep(0.2) # let the job start waiting on the lock
pr._lock.release()
return await task
run = _run(scenario())
assert run.status == "success"
assert not pr._lock.locked()
def test_scheduled_run_skips_after_lock_wait_timeout():
async def scenario():
await pr._lock.acquire()
try:
return await pr.run_job("test:waited_out", ["true"], triggered_by="schedule", lock_wait=0.2)
finally: finally:
pr._lock.release() pr._lock.release()