fix: keep proc liveness usable when a proc file is damaged
Summary
A quality audit of internal/persis/file/proc (chosen as the highest bug-density core package: test ratio 0.16, and it answers "is this DAG run alive?") turned up one production bug and one piece of dead configuration. Net −67 lines of production code; the only additions are two regression tests.
1. One damaged proc file could stop a queue and brick the CLI
Listing proc entries treated any undecodable file as fatal for the whole group:
entry, err := readProcEntryWithRetry(file, groupName, s.staleTime, now)
if err != nil {
if errors.Is(err, os.ErrNotExist) { continue }
return nil, err // <-- one bad file fails the entire group
}
Measured before the fix — one healthy run plus one truncated .proc file dropped in the group directory:
BEFORE corruption: CountAlive=1 err=<nil>
AFTER corruption: CountAlive=0 err=invalid proc file: ... shorter than the 8-byte heartbeat header
AFTER corruption: IsRunAlive=false err=invalid proc file: ...
AFTER corruption: LatestHeartbeat!=nil=true err=<nil> <-- tolerant path already existed
Blast radius:
queue_dispatcher.go:553—CountAlivefailing abortsselectDispatchBatchand marks the queue unavailable, so the queue stops dispatching entirelyscheduler.go:216— breaksIsRunning, blocking scheduled runsinternal/cmd/context.go:330—NewContextvalidates the proc directory on every command, so one damaged file made the whole CLI unusableruntime/manager.go(5 sites) andhumantask/resume.golose liveness;dagu ps/dagu rmfail
It could not self-heal: the zombie detector reaches RemoveIfStale only through the listing that was failing, so recovery meant deleting the file by hand.
LatestHeartbeat in the same file already skipped exactly this condition with a comment explaining why, so the fix adopts the behavior the package already demonstrated. Validate now means "the proc directory is usable" rather than "every file in it decodes" — which is what its one caller actually wants.
2. Clock skew caused the same group-wide failure, permanently
A heartbeat more than five minutes ahead of the reader was rejected as an invalid file. Since the heartbeat carries the writer's clock and is judged against the reader's, skew between hosts in distributed mode triggered finding 1 — and it never aged out, because the skewed writer kept rewriting the file. A future heartbeat is now treated as not fresh, so the entry stays stale and the zombie detector can reap it. This also makes a garbled timestamp self-healing instead of permanently poisoning the group.
3. proc.heartbeat_sync_interval did nothing
The setting was parsed, defaulted to 10s, aliased from the deprecated scheduler. key, exposed in the JSON schema and as DAGU_PROC_HEARTBEAT_SYNC_INTERVAL, and validated against proc.stale_threshold — while both proc store implementations accepted it as a no-op. An operator could be blocked from starting Dagu by a constraint on a value nothing reads.
Removed with its plumbing. Existing configs carrying the key keep loading (the main decoder ignores unknown keys — ErrorUnused is scoped only to auth.proxy); only the validation that rejected it is gone, which is strictly more permissive.
Cleanups (all subtractive)
- Collapsed two byte-identical read-retry helpers into one
- Stopped sleeping after the final retry attempt (wasted 300ms on every exhausted retry)
procMetaFromLegacyData→procMetaFromData; it decodes the current format, not just legacy- Dropped a local shadowing the builtin
copy
Package average cyclomatic complexity 4.5 → 4.6 across 969 → 948 lines with 15 fewer functions.
Not addressed
RemoveIfStale has a check-then-delete window against a process that resumes heartbeating; closing it means holding the store's group lock across the re-read and removal. That adds code, so it is left out of a change whose goal was reduction. Worth a follow-up — it can delete a live run's proc file, making the run invisible to IsRunAlive.
Tests
Two behavior-level regression tests: a damaged file no longer hides a healthy run in its group (and Validate still passes), and a future heartbeat is stale and reapable.
make fmt clean including GOOS=windows. go test -race green on persis/..., cmn/config/..., service/scheduler/..., service/frontend/api/v1/..., cmd/..., and all of intg/... including the distributed and queue suites.
Summary by cubic
Makes proc liveness resilient and conservative: damaged .proc files no longer wedge queues/CLI, undercount alive runs, or mislead retries while files are still being written. Also removes the dead proc.heartbeat_sync_interval from loading/env while keeping it in the schema as deprecated and ignored, and bounds test waits by the test deadline.
-
Bug Fixes
internal/persis/file/proclistings skip undecodable.procfiles only after they’re abandoned (past the stale threshold); files still being written surface as errors.LatestHeartbeatfilters to files that may belong to the requested run (by DAG dir and encoded IDs) and applies the same abandonment gate; unrelated damage no longer fails lookups, and damage that may belong returns an error to prevent duplicates.Validatechecks that the proc directory is readable, not that every file decodes.- Freshness prefers file write time, then heartbeat: future-dated heartbeats are stale unless the file was written recently.
- Tests: bounded
statusandrestartwait helpers to the test deadline.
-
Refactors
- Removed
WithHeartbeatSyncInterval,WithProcHeartbeatSyncInterval, and theproc.heartbeat_sync_intervalloader/env aliases; the JSON schema now accepts the key as deprecated and ignored so existing configs still validate. - Consolidated read-retry helpers and removed the final sleep; cleaned up tests and E2E config.
- Removed
Summary by CodeRabbit
-
Breaking Changes
- Removed the deprecated
heartbeat_sync_intervalsetting from process and scheduler configuration. - Configuration schemas, environment bindings, and legacy aliases no longer accept this setting.
- Removed the deprecated
-
Bug Fixes
- Improved resilience when reading damaged process state files; valid entries remain accessible.
- Future-dated heartbeats are now correctly treated as stale.
- Improved retry behavior by avoiding unnecessary delays after the final attempt.