mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix: stop ageing files out entirely, except staging folders
The strengthened race test earned its keep on the first run: the sweep deleted an aged 'rejected upload' while a save was committing a reference to it, and the accepted config came out pointing at nothing. The write lock serializes the two but cannot help when the sweep goes first. So the age rule is gone for plans and for marker folders. What remains is one sentence: a file goes when an action says so — a plan replaced, an attachment dropped from a device that still exists — plus a per-dialog staging folder after an hour, which by construction can only hold an upload nobody saved. Cost: an upload whose save failed sits there until someone removes it by hand. That is the side of the trade the owner picked, and it is the side that cannot lose data.
This commit is contained in:
@@ -23,14 +23,11 @@ MAX_SIGN_PATHS = 200
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# The scheduled sweep is a different judgement from a commit's. A commit knows
|
||||
# it replaced a file; the timer only knows nobody points at one right now — and
|
||||
# "nobody points at it" is a normal, reversible state. Detaching a plan (switch
|
||||
# a space to "draw") leaves the image on disk on purpose, and re-attaching it
|
||||
# later is a thing people do. On 2026-07-28 the hourly rule applied to that case
|
||||
# and removed two plans the owner had detached weeks earlier; they were not
|
||||
# recoverable. So the timer waits a month, and never touches a plan or an
|
||||
# attachment that still belongs to something in the configuration.
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S, SCHEDULED_GRACE_S
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -123,11 +123,10 @@ def collect_attachments(
|
||||
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. If the marker itself is gone, that is a different
|
||||
transition and the files are kept, like a deleted space's plan. A staging
|
||||
folder (`up_*` — only ever a dialog that was never saved) is collected after
|
||||
PLAN_ORPHAN_TTL_S, anything else after SCHEDULED_GRACE_S. Never raises: it
|
||||
runs behind a durable write.
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
@@ -141,7 +140,6 @@ def collect_attachments(
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
cutoff = now_s - SCHEDULED_GRACE_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
@@ -153,7 +151,6 @@ def collect_attachments(
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
limit = staging_cutoff if staging else cutoff
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
@@ -164,15 +161,14 @@ def collect_attachments(
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging and folder.name not in live_markers:
|
||||
# No device owns this folder any more. Whether the file was
|
||||
# attached once or is a leftover upload we cannot tell, and
|
||||
# by the standing rule that means we keep it. (A staging
|
||||
# folder is exempt above: it has no device by construction.)
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
# the device is there and never listed this file: a rejected upload
|
||||
try:
|
||||
if item.stat().st_mtime >= limit:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
@@ -275,8 +271,6 @@ def collect_plans(
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
now_s = time.time() if now is None else now
|
||||
reject_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
@@ -290,26 +284,21 @@ def collect_plans(
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
if item.name not in replaced:
|
||||
# Not a replacement. PRODUCT RULE (owner's decision, 2026-07-28):
|
||||
# a file we were not told to delete is kept, however long it sits
|
||||
# there. Detaching a plan is one click to undo and the editor says
|
||||
# the image stays; deleting a space is deliberate but the image is
|
||||
# usually something the user imported and may not have elsewhere.
|
||||
# The two errors are not symmetrical — a few unnecessary megabytes
|
||||
# can always be removed by hand, a file we should not have removed
|
||||
# cannot be brought back.
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# The single exception: a space that HAS a plan, and another file of
|
||||
# its own that has never been the plan. That can only be an upload
|
||||
# whose save was rejected, and an hour is plenty for it.
|
||||
space = item.name.split(".")[0]
|
||||
if not new_by_space.get(space) or item.name in old_by_space.values():
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= reject_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
|
||||
Reference in New Issue
Block a user