2026-09-04 02:27:32 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-09-01 22:36:13 -04:00
|
|
|
"""Hand the archive's file handles back to the host.
|
|
|
|
|
|
2026-09-04 02:27:32 -04:00
|
|
|
The archive is on a virtiofs share whose daemon holds a host file descriptor per inode
|
|
|
|
|
the guest has looked up. Anything that reads a lot of the archive leaves those inodes
|
|
|
|
|
cached, and the handles with them, until something forces the guest to evict them -- at
|
|
|
|
|
which point the daemon can run out and the mount starts refusing *every* open, which
|
|
|
|
|
surfaces across the whole machine as "too many open files in system".
|
2026-09-01 22:36:13 -04:00
|
|
|
|
2026-09-04 02:27:32 -04:00
|
|
|
The pipeline's own bulk jobs reclaim as they go and again when they finish, so this
|
|
|
|
|
should rarely be needed. It exists for when something else has loaded the cache:
|
2026-09-01 22:36:13 -04:00
|
|
|
|
|
|
|
|
reclaim.py # report, then reclaim if needed
|
|
|
|
|
reclaim.py --check # report only, change nothing
|
|
|
|
|
sudo reclaim.py # uses drop_caches: instant, and far gentler
|
|
|
|
|
|
2026-09-04 02:27:32 -04:00
|
|
|
**This file deliberately imports nothing from `suvi`, and nothing outside the standard
|
|
|
|
|
library.** It has to run in exactly the situation it exists for, and in that situation
|
|
|
|
|
the archive mount refuses every open -- which means the venv interpreter (which lives on
|
|
|
|
|
the mount), the `suvi` package, and even Python's scan of the working directory all
|
|
|
|
|
fail with ENFILE before any of this code runs. Recovery went like this once:
|
|
|
|
|
|
|
|
|
|
$ ./.venv/bin/python reclaim.py
|
|
|
|
|
bash: ./.venv/bin/python: Too many open files in system
|
|
|
|
|
$ /usr/bin/python3 -c ...
|
|
|
|
|
OSError: [Errno 23] Too many open files in system: '.../scripts'
|
|
|
|
|
|
|
|
|
|
So: no package imports, and run it off the root filesystem with an isolated interpreter,
|
|
|
|
|
which keeps the mount out of `sys.path` entirely:
|
|
|
|
|
|
|
|
|
|
/usr/bin/python3 -I ~/reclaim.py --force
|
|
|
|
|
|
|
|
|
|
`--install` writes that copy for you, to a path that is not on the share.
|
|
|
|
|
|
|
|
|
|
Without root the only lever is memory pressure, which means briefly allocating several
|
|
|
|
|
GiB. Running under sudo avoids that entirely.
|
2026-09-01 22:36:13 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import os
|
2026-09-04 02:27:32 -04:00
|
|
|
import shutil
|
2026-09-01 22:36:13 -04:00
|
|
|
import sys
|
|
|
|
|
|
2026-09-04 02:27:32 -04:00
|
|
|
#: Roughly the slab size at which the daemon is likely near a 1M descriptor limit. A
|
|
|
|
|
#: cached dentry plus inode is on the order of a kilobyte, so a gigabyte of reclaimable
|
|
|
|
|
#: slab is on the order of a million inodes.
|
|
|
|
|
CONCERN_GIB = 1.0
|
|
|
|
|
#: Leave at least this much memory free while applying pressure.
|
|
|
|
|
RESERVE_GIB = 2.0
|
|
|
|
|
#: Never allocate more than this in total.
|
|
|
|
|
MAX_GIB = 48
|
|
|
|
|
#: Below this, the cache is already small enough that pressure achieves nothing.
|
|
|
|
|
FLOOR_KB = 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def meminfo(key):
|
|
|
|
|
try:
|
|
|
|
|
with open("/proc/meminfo") as handle:
|
|
|
|
|
for line in handle:
|
|
|
|
|
if line.startswith(key):
|
|
|
|
|
return int(line.split()[1])
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
return 0
|
2026-09-01 22:36:13 -04:00
|
|
|
|
|
|
|
|
|
2026-09-04 02:27:32 -04:00
|
|
|
def reclaimable_kb():
|
|
|
|
|
return meminfo("SReclaimable")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def available_gib():
|
|
|
|
|
return meminfo("MemAvailable") / (1024 * 1024)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def drop_caches():
|
|
|
|
|
"""The direct path: ask the kernel to drop dentries and inodes. Needs root."""
|
|
|
|
|
try:
|
|
|
|
|
with open("/proc/sys/vm/drop_caches", "w") as handle:
|
|
|
|
|
handle.write("2\n")
|
|
|
|
|
return True
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def release_handles(reserve_gib=RESERVE_GIB, max_gib=MAX_GIB, floor_kb=FLOOR_KB):
|
|
|
|
|
"""Force the guest to evict cached inodes, so the daemon can close their handles."""
|
|
|
|
|
if drop_caches():
|
|
|
|
|
return True
|
|
|
|
|
before = reclaimable_kb()
|
|
|
|
|
if before <= 0:
|
|
|
|
|
return False
|
|
|
|
|
if before < floor_kb:
|
|
|
|
|
return True
|
|
|
|
|
target = before // 2
|
|
|
|
|
blocks = []
|
|
|
|
|
try:
|
|
|
|
|
for _ in range(int(max_gib)):
|
|
|
|
|
if reclaimable_kb() <= target:
|
|
|
|
|
return True
|
|
|
|
|
if available_gib() <= reserve_gib:
|
|
|
|
|
break
|
|
|
|
|
blocks.append(bytearray(1024**3))
|
|
|
|
|
except MemoryError:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
blocks.clear()
|
|
|
|
|
return reclaimable_kb() <= target
|
2026-09-01 22:36:13 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def report(prefix):
|
2026-09-04 02:27:32 -04:00
|
|
|
slab = reclaimable_kb() / (1024 * 1024)
|
2026-09-01 22:36:13 -04:00
|
|
|
print(f"{prefix:>8}: {slab:.2f} GiB reclaimable slab "
|
2026-09-04 02:27:32 -04:00
|
|
|
f"(~{slab:.1f}M cached inodes), {available_gib():.1f} GiB available")
|
2026-09-01 22:36:13 -04:00
|
|
|
return slab
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 02:27:32 -04:00
|
|
|
def install(destination):
|
|
|
|
|
"""Copy this script somewhere that is not on the share it rescues."""
|
|
|
|
|
destination = os.path.abspath(os.path.expanduser(destination))
|
|
|
|
|
if os.path.realpath(destination) == os.path.realpath(os.path.abspath(__file__)):
|
|
|
|
|
raise SystemExit("refusing to install over the original")
|
|
|
|
|
shutil.copyfile(os.path.abspath(__file__), destination)
|
|
|
|
|
os.chmod(destination, 0o755)
|
|
|
|
|
print(f"Installed to {destination}")
|
|
|
|
|
print(f" when the mount is refusing opens, run: /usr/bin/python3 -I {destination}")
|
|
|
|
|
return destination
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
def main(argv=None):
|
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
|
|
|
parser.add_argument("--check", action="store_true", help="report only, reclaim nothing")
|
|
|
|
|
parser.add_argument("--force", action="store_true", help="reclaim even if it looks fine")
|
2026-09-04 02:27:32 -04:00
|
|
|
parser.add_argument("--install", nargs="?", const="~/reclaim.py", default=None,
|
|
|
|
|
metavar="PATH",
|
|
|
|
|
help="copy this script off the share so it survives ENFILE")
|
2026-09-01 22:36:13 -04:00
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
|
2026-09-04 02:27:32 -04:00
|
|
|
if args.install:
|
|
|
|
|
install(args.install)
|
|
|
|
|
return 0
|
|
|
|
|
|
2026-09-01 22:36:13 -04:00
|
|
|
before = report("before")
|
|
|
|
|
if args.check:
|
|
|
|
|
print(" (check only; nothing reclaimed)")
|
|
|
|
|
return 0 if before < CONCERN_GIB else 1
|
|
|
|
|
|
|
|
|
|
if before < CONCERN_GIB and not args.force:
|
|
|
|
|
print(f" below {CONCERN_GIB} GiB; nothing to do. Use --force to reclaim anyway.")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
if os.geteuid() != 0:
|
|
|
|
|
print(" not root, so using memory pressure; run under sudo for the direct path")
|
2026-09-04 02:27:32 -04:00
|
|
|
freed = release_handles()
|
2026-09-01 22:36:13 -04:00
|
|
|
after = report("after")
|
2026-09-04 02:27:32 -04:00
|
|
|
print(f" {'reclaimed' if freed else 'no change'}: {before - after:+.2f} GiB")
|
2026-09-01 22:36:13 -04:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|