diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 01:30:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 01:30:47 +0200 |
| commit | 762233772162a05be67432aa551a430b939250de (patch) | |
| tree | 311a2f72fef30fec6017313c55ed5beefcf0237e /packages/meshbay-node/src/meshbay_node/ops/files.py | |
| parent | 320620a18399eb43c4d9056e9fe4c3ffac8fcfd4 (diff) | |
| download | meshbay-762233772162a05be67432aa551a430b939250de.tar.gz | |
refactor(node): split ops.py into the ops package
Each section of ops.py becomes a module of meshbay_node/ops/ (core,
node_toml, members, chat, groups, roots, files, settings, apps), cut as
text; ops/__init__.py keeps the docstring and re-exports every name, so
`ops.<name>` is unchanged for every caller. Logger name unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops/files.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops/files.py | 114 |
1 files changed, 114 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops/files.py b/packages/meshbay-node/src/meshbay_node/ops/files.py new file mode 100644 index 0000000..4609627 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/ops/files.py @@ -0,0 +1,114 @@ +"""Files on disk and the index cache.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from meshbay_node.ops.core import OpError, _group_ctx + +log = logging.getLogger("meshbay_node.ops") + + +# ── Files ──────────────────────────────────────────────────────────────────── + +async def delete_file(state: dict, group_id: str, file_id: str) -> dict: + """ + Remove a file from a group. Milestone 14.11 — the last operator action that + needed a browser. + + Authorization happened in the adapter. On the loopback path that is the + session token, which means physical or SSH access to the machine hosting the + files — an operator who can run this can also `rm` the file, so the check is + not weaker than the alternative. + """ + ctx = _group_ctx(state, group_id) + index = ctx.get("index") + roots = ctx.get("roots") + if not index or not roots: + raise OpError("Group has no index", status=503) + + entry = index.get_entry(file_id) + if not entry: + raise OpError("No such file in this group", status=404) + + from meshbay_node.roots import entry_abs_path + path = entry_abs_path(roots, entry) + if path is None: + raise OpError( + f"{entry.name!r} is in root {entry.path.split('/')[0]!r}, which is " + f"not readable right now — the file is frozen, not gone", status=409) + + try: + path.unlink() + except FileNotFoundError: + # Already gone from disk; drop the stale entry rather than refusing. + log.warning("Index named a file that is not on disk: %s", path) + except OSError as e: + raise OpError(f"Cannot delete {entry.name!r}: {e}", status=500) from e + + index.remove_entry(file_id) + log.info("File deleted by operator: %s/%s", entry.path, entry.name) + return {"status": "deleted", "name": entry.name, "path": entry.path, + "group_id": group_id} + + +# ── Index cache maintenance ─────────────────────────────────────────────────── +# +# The (path, size, mtime) -> hash accelerator (indexer/cache.py) is node-wide +# and grows for as long as a path was ever seen — a folder an operator later +# stops sharing (root removed, or every group hosting it is deleted) leaves +# its rows behind forever otherwise. Nothing about correctness needs this: +# a stale row just sits unused (lookup() keys on the live path string, so a +# path nothing scans any more is never looked up). This is disk space +# hygiene the operator can run when they want it, not a background job. + +async def index_cache_stats(state: dict) -> dict: + """Row count only — cheap, safe to call on every dashboard render. + The actual staleness check (prune_index_cache) is not this cheap and + must never run implicitly.""" + cache = state.get("index_cache") + return {"count": await cache.count() if cache else 0} + + +async def prune_index_cache(state: dict) -> dict: + """ + Drop cache rows that cannot be right for anything any more: the path is + not under any group's root at all, or it is under a root that is + available right now and the file is genuinely gone from disk. + + Deliberately leaves alone anything under a root that is currently + *unavailable* (a disconnected drive) — indexer.py's own rule is that + such a root freezes rather than empties, precisely so it does not pay a + full rehash the moment it comes back. Pruning through an unavailable + root here would reintroduce exactly that cost via a different door, so + an owning-but-unavailable root wins over "the file isn't there right + now" every time, unconditionally. + + A row lost here costs one rehash the next time that path is scanned, + never a wrong answer: lookup() (cache.py) always re-validates size and + mtime against a live stat() before trusting a cached hash. + """ + cache = state.get("index_cache") + if cache is None: + raise OpError("No index cache in this process", status=503) + + indexers = list((state.get("indexers") or {}).values()) + roots = [root for indexer in indexers for root in indexer.roots] + + def _is_stale(path_str: str) -> bool: + path = Path(path_str) + owning = [r for r in roots if r.path in path.parents] + if not owning: + return True + if any(not r.available for r in owning): + return False + return not path.exists() + + paths = await cache.all_paths() + stale = await asyncio.to_thread(lambda: [p for p in paths if _is_stale(p)]) + await cache.remove_many(stale) + log.info("Index cache pruned: %d stale row(s) removed, %d kept", + len(stale), len(paths) - len(stale)) + return {"status": "pruned", "removed": len(stale), "kept": len(paths) - len(stale)} |