Repository navigation
fix(service): expire and reconcile task records stuck in active states - #6143
akushonkamen wants to merge 1 commit into
Conversation
A task record whose finalization was missed (process died between persisting the result and running _finalize_task, or the last work item was released through a path that does not re-finalize) stayed at running forever: _is_expired only gave TTLs to terminal states, so the cleanup loop was a no-op for it and it remained on disk indefinitely. - give active statuses (pending/running/cancelling) a 7-day recovery window (TTL_ACTIVE) so orphaned records become expirable; the expiry check stays a pure age comparison because _evict_expired consults it under the tracker lock and TaskWorkIndex.has_work takes its own lock, so collectors confirm has-work before collecting instead - guard _delete_expired_task: a record that still owns queue work is never collected, however stale - reconcile at startup: restore_work_tasks now finalizes persisted records still in an active state that own no rebuilt queue work; _finalize_task derives the outcome from what was persisted (result -> completed, error or nothing -> failed) and appends the terminal status_changed event - add PersistentTaskStore.list_all for the startup scan, skipping unreadable accounts so one corrupt file cannot disable reconciliation Fixes volcengine#6130 Signed-off-by: akushonkamen <akushonkamen@163.com>
|
Hi — triaging the failing check: the failure is environmental (fork PRs can't access the embedding credentials), not caused by this PR's changes. What failed: In "API & CLI Integration Tests (ubuntu-24.04)", step Why it happens: this workflow triggers on Evidence it is not this branch:
Whether the integration lane should gate fork PRs at all (e.g. a maintainer dispatch with secrets, or treating the job as advisory for forks) is a maintainer decision — happy to re-run or adjust if there's a preferred path. |
Description
A task record whose finalization was missed stayed at
runningforever. If the process died between persistingresultand running_finalize_task, or the last work item was released through a path that does not re-finalize, the record kept reporting work in progress while the work had finished long ago (the issue observed tensession_committasks stuck for 19–21 h, each with a populatedresultand noerror).Root cause (real execution path).
TaskTracker._is_expired(openviking/service/task_tracker.py,_is_expiredat L471–475 on6006fc3; the issue cites the older L308–313) only gave a TTL to terminal states —COMPLETED/CANCELLED(24 h) andFAILED(7 d).RUNNINGmatched no branch, so it returnedFalseunconditionally, the cleanup loop (_evict_expired, everyCLEANUP_INTERVAL = 300 s) was a no-op for it, and the record stayed on disk indefinitely._finalize_task(L905–913) early-returns whileTaskWorkIndex.has_work(task_id)is true, so a missed call was never retried. Startup reconciliation did not help either:prepare_task_tracking→restore_work_tasksonly loaded the records referenced by rebuilt QueueFS work and never closed an active record whose work was gone.Behavior before → after.
runningrecord (no queue work,updated_at8 days old) →_is_expiredisFalse; never evicted; stillrunningafter restart.pending/running/cancelling) get a 7-day recovery window (TTL_ACTIVE), so an orphaned record becomes expirable and is collected by the cleanup loop; and at startuprestore_work_tasksfinalizes every persisted active record that owns no rebuilt work, deriving the terminal state from what was persisted (result→completed,error/nothing →failed) and appending the missing terminalstatus_changedevent. A genuinely-running task that still owns queue work is never collected, however stale its record looks.Entrypoint / owner module.
openviking/service/task_tracker.py(TaskTracker),openviking/service/task_store.py(PersistentTaskStore) — Platform / tasks.Human Involvement
Related Issue
Fixes #6130
Type of Change
Changes Made
task_tracker.py: addTTL_ACTIVE = 604_800(7 days) and make_is_expiredreturnage > TTL_ACTIVEfor active statuses. The check is deliberately kept a pure age comparison:_evict_expiredconsults it while holding the tracker lock, andTaskWorkIndex.has_worktakes its own lock; the work-index callbacks (finalize_before_ack/register) already run index-lock → tracker-lock, so callinghas_workfrom inside_is_expiredwould create an ABBA deadlock. The has-work confirmation therefore lives in the collectors.task_tracker.py: guard_delete_expired_task— an active record for whichhas_work(task_id)is still true is never collected (only the recovery window passed)._prune_persisted_expiredalready had the equivalent guard.task_tracker.py: startup reconciliation —restore_work_tasksnow calls_reconcile_abandoned_tasks(owners), which scans all persisted records viastore.list_all(), skips anything inownersor still owning work, and runs_finalize_taskon remaining active records. It runs once at boot before any consumer starts, so an active record with no rebuilt work can only be a leftover from a process that died before finalizing. Malformed records are logged and skipped per record.task_store.py: addPersistentTaskStore.list_all()(and theTaskStoreprotocol method) for the startup scan. It iterates account/user directories under the task root and skips unreadable accounts, so one corrupt file cannot disable reconciliation. Scoped readers keep usinglist/list_page.tests/test_task_tracker.py: 7 new tests —test_is_expired_gives_active_statuses_a_recovery_window,test_evict_reclaims_orphaned_running_record,test_evict_keeps_running_task_that_still_owns_work,test_ttl_terminal_behaviour_unchanged,test_restore_work_tasks_finalizes_orphaned_running_record,test_restore_work_tasks_finalizes_interrupted_orphan_as_failed,test_restore_work_tasks_leaves_owned_active_records_alone.Four of them fail on
mainand pass with this change.No public API change. Terminal-state TTLs (
TTL_COMPLETED,TTL_FAILED) are unchanged (covered bytest_ttl_terminal_behaviour_unchanged). No migration: existing orphaned records are settled by the startup reconciliation on the next boot, or collected by the cleanup loop once they are older than 7 days.Testing
The issue was reproduced, not only inferred from the code. Red/green with a standalone script that builds
TaskRecord(status=RUNNING, updated_at=now-8d)on aTaskTrackerwhose work index holds nothing for the task and asserts_is_expired(...) is True:main(6006fc3):_is_expired=False→AssertionError, exit 1 (red)_is_expired=True, exit 0 (green)Validation commands run against this commit (macOS, Python 3.13):
pytest tests/test_task_tracker.py tests/test_task_tracker_concurrency.py -q --no-cov→ 117 passed (includes the 7 new tests).ruff check openviking/service/task_store.py openviking/service/task_tracker.py tests/test_task_tracker.py→ All checks passed;ruff format --checkon the same three files → 3 files already formatted.timeout 300 pytest tests/unit -q --no-cov --ignore=tests/unit/service/test_core_embedder_lifecycle.py→ 49 failed / 1979 passed / 2 skipped on this branch and the identical 49 failed / 1979 passed / 2 skipped onmain, with a byte-identicalFAILEDlist on both sides — i.e. zero regressions. The 49 pre-existing failures are environmental (the RAGFS native binding is not initialized in this environment,RAGFSBindingClient will be None), andtests/unit/service/test_core_embedder_lifecycle.pyhangs identically onmainand on this branch, which is why it is excluded.tests/test_task_backend_config.py tests/unit/service/test_session_auto_commit.py tests/unit/service/test_core_consistency.py tests/storage→ 34 failed + 84 errors, identical tomainline-by-line (same RAGFS-binding environment cause).Checklist
Screenshots (if applicable)
N/A
Additional Notes
_is_expiredcannot consult the work index (lock ordering above), andupdated_atalone cannot distinguish a long-running task from an orphan. The window matchesTTL_FAILED; the startup reconciliation is what settles orphans promptly, the window is the backstop for a process that keeps running after the finalize was missed._reconcile_abandoned_tasksusesgetattr(store, "list_all", None)so an alternateTaskStoreimplementation withoutlist_allsimply skips reconciliation instead of failing boot.