Let a stuck backup be stopped instead of stranding the stack (0.67.0)
CI / check (push) Successful in 13m33s
CI / build-and-push (push) Successful in 1m51s

A backup holds the stack lock for up to twelve hours — a large volume
legitimately takes that long — and stops the stack's containers for a
consistent snapshot. One that died without unwinding therefore left the
stack stopped and refusing every start, stop, restart and deploy with
"Stack 'npmplus' is busy: backup in progress", with nothing to click
until the TTL ran out.

Backups and restores now register themselves in stack_job_service while
they run, with their phase and the helper containers they stream
through. Cancelling sets a flag and removes those containers, which is
what actually ends the long read: the backup then unwinds through its
own finally — the half-written archive goes, the containers it stopped
are started again, the lock is released. Checks sit between two files
and two volumes, never inside one. A restore is not cancellable at all;
it is halfway through writing a stack's data back.

POST /api/stacks/{id}/unlock does that and waits for it. A backup lock
with no job behind it is a leftover, so it is released on one click; a
deploy lock takes an explicit force, because compose keeps running past
its request. GET /api/stacks/{id}/lock and the stack detail response say
what holds the stack and since when, and the stack page turns that into
"Stop backup" / "Release lock".

Startup now clears every lock rather than only expired ones: after a
restart nothing of ours is in flight, the same reasoning the leftover
helper containers already follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5 committed 2026-09-21 13:39:17 +02:00
1 parent 052a85067b
commit 270ddda51d
19 files changed
+1018 -22

No files matched your search

+97
View File
@@ -277,6 +277,103 @@ def test_failed_volume_is_an_error_and_listed_as_skipped(stack, docker):
for s in manifest["skipped"])
# --------------------------------------------------------------------------- #
# Cancelling a backup
# --------------------------------------------------------------------------- #
#
# A backup holds the stack lock for up to twelve hours and stops the stack's
# containers for a consistent snapshot. One that hangs — an unreachable share,
# a helper container reading a volume that never ends — therefore used to
# leave the stack stopped *and* refusing every start, stop and restart with
# "busy: backup in progress" until the TTL ran out. It can be stopped now, and
# stopping it has to put back exactly what the backup took away.
def _cancelling_export(stack_id, docker):
"""An export that is cancelled halfway, the way the endpoint does it:
the helper container is removed, so the read fails."""
def export_volume(name, dest_file):
from services import stack_job_service
stack_job_service.cancel(stack_id, "tester")
raise RuntimeError("helper container removed while reading the volume")
return export_volume
def test_cancelling_a_backup_starts_the_stack_again(stack, docker, monkeypatch):
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.container("app", "running")
monkeypatch.setattr(assets, "export_volume", _cancelling_export(stack_id, docker))
with pytest.raises(backup_service.BackupCancelled):
run(backup_service.create_backup_ex(stack_id, "S"))
assert docker.compose_calls == [["stop", "app"], ["start", "app"]]
assert [c.state for c in docker.containers] == ["running"]
def test_a_cancelled_backup_frees_the_lock(stack, docker, monkeypatch):
"""The reason to cancel at all: the stack must be usable again."""
from sqlmodel import Session
from database import engine
from services import stack_lock_service
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.container("app", "running")
monkeypatch.setattr(assets, "export_volume", _cancelling_export(stack_id, docker))
with pytest.raises(backup_service.BackupCancelled):
run(backup_service.create_backup_ex(stack_id, "S"))
with Session(engine) as session:
assert not stack_lock_service.is_busy(session, stack_id)
def test_a_cancelled_backup_leaves_no_half_written_archive(stack, docker, monkeypatch):
import glob
import tempfile as tempfile_mod
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.container("app", "running")
def cancel_while_writing(name, dest_file):
from services import stack_job_service
with tarfile.open(dest_file, "w"):
pass
stack_job_service.cancel(stack_id, "tester")
return os.path.getsize(dest_file)
monkeypatch.setattr(assets, "export_volume", cancel_while_writing)
with pytest.raises(backup_service.BackupCancelled):
run(backup_service.create_backup_ex(stack_id, "S"))
leftovers = glob.glob(os.path.join(tempfile_mod.gettempdir(), f"sp-{stack_id}-*.tar.gz"))
assert leftovers == []
def test_a_backup_nobody_cancelled_is_not_disturbed(stack, docker):
"""The check runs on every file and volume; it must only fire when asked."""
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.volumes[f"{stack_id}_db"] = {"files": {"a": b"1"}, "labels": {}}
docker.container("app", "running")
path, report = run(backup_service.create_backup_ex(stack_id, "S"))
try:
assert report["errors"] == []
assert {v["name"] for v in report["volumes"]} == {f"{stack_id}_db"}
finally:
os.unlink(path)
def test_file_that_shrinks_while_read_keeps_the_archive_valid(tmp_path, monkeypatch):
root = tmp_path / "stack"
root.mkdir()