Let a stuck backup be stopped instead of stranding the stack (0.67.0)
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:
1 parent
052a85067b
commit
270ddda51d
19 files changed
+1018
-22
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user