Skip to content

Commit

Permalink
Garbage-collect based on last use time rather than creation time.
Browse files Browse the repository at this point in the history
  • Loading branch information
rblank committed Jan 10, 2025
1 parent 1e40ce3 commit dcc9015
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 52 deletions.
106 changes: 62 additions & 44 deletions [email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import contextlib
import contextvars
import itertools
import os
import pathlib
import re
import subprocess
Expand All @@ -13,14 +15,10 @@
import time
import venv

# TODO: Don't make upgrade.txt a requirements file, just metadata
# TODO: Garbage-collect based on the time of last use (touch requirements.txt)
# TODO: Identify existing venv through requirements
# TODO: Allow forcing requirements (file? env var?)
# TODO: Allow forcing the creation of a new venv

keep_envs = 2
keep_envs_days = 3
idle_days = 3
keep_live_envs = 2

executable_re = re.compile(r'^run-([^@]+)@(.+)\.py$')

Expand All @@ -30,6 +28,8 @@ def main(argv, stdin, stdout, stderr):
executable = pathlib.Path(argv[0]).name
if (m := executable_re.fullmatch(executable)) is not None:
command, requirements = m.group(1, 2)
if reqs := os.environ.get('RUN_REQUIREMENTS'):
requirements = reqs
elif len(argv) >= 3:
command, requirements = argv[1:3]
argv = argv[:1] + argv[3:]
Expand All @@ -43,37 +43,35 @@ def main(argv, stdin, stdout, stderr):

# Find the most recent venv. Create one if none exists.
envs = builder.find()
if not any(e.valid for e in envs):
if not (es := envs.setdefault(requirements, [])):
stderr.write("Creating venv...\n")
env = builder.new()
env.create(f'{requirements}\n')
envs.insert(0, env)
env.create(requirements)
es.insert(0, env)
stderr.write("\n")
for env in envs:
if env.valid: break
else:
env = es[0]

# Garbage-collect old venvs.
limit = time.time_ns() - keep_envs_days * 24 * 3600 * 1_000_000_000
count = 0
for e in envs:
if e.valid:
count += 1
if count <= keep_envs: continue
if e.time >= limit: continue
e.remove()
limit = time.time_ns() - idle_days * 24 * 3600 * 1_000_000_000
for reqs, es in envs.items():
keep = keep_live_envs if is_live(reqs) else 0
for e in itertools.islice(es, keep):
if e.last_used < limit: e.remove()

# Upgrade if available and if requested by the user.
if (reqs := env.check_upgrade()) is not None:
if env.want_upgrade():
stderr.write("Upgrading...\n")
new = builder.new()
try:
new.create(reqs)
new.create(requirements)
env = new
except Exception:
stderr.write("\nUpgrade failed. Continuing with current version.\n")
stderr.write("\n")

# Run the command.
env.touch()
bin, ext = env.sysinfo
return subprocess.run([pathlib.Path(bin) / f'{command}{ext}'] + argv[1:],
cwd=base).returncode
Expand All @@ -90,6 +88,12 @@ def __get__(self, instance, owner=None):
return res


live_re = re.compile(r'(?i)^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$')

def is_live(requirements):
return live_re.fullmatch(requirements or '') is not None


class Env:
prefix = 'venv'
requirements_txt = 'requirements.txt'
Expand All @@ -101,15 +105,19 @@ def __init__(self, path, builder):
self.path, self.builder = path, builder

@lazy
def time(self):
return int(self.path.stem.rsplit('-', 1)[-1], 16)
def last_used(self):
try:
return (self.path / self.requirements_txt).stat(
follow_symlinks=False).st_mtime_ns
except OSError:
return 0

@lazy
def valid(self):
with contextlib.suppress(IOError):
(self.path / self.requirements_txt).read_text()
return True
return False
def requirements(self):
try:
return (self.path / self.requirements_txt).read_text()
except OSError:
return None

@lazy
def sysinfo(self):
Expand All @@ -118,21 +126,22 @@ def sysinfo(self):
return (sysconfig.get_path('scripts', scheme='venv', vars=vars),
sysconfig.get_config_vars().get('EXE', ''))

def check_upgrade(self):
def want_upgrade(self):
if not is_live(self.requirements): return False
try:
reqs = (self.path / self.upgrade_txt).read_text()
upgrade = (self.path / self.upgrade_txt).read_text()
cur, new = upgrade.split(' ', 1)[:2]
except Exception:
return
return False
self.builder.out.write(f"""\
A t-doc upgrade is available:
{''.join(f' {line}\n' for line in reqs.splitlines())}\
A t-doc upgrade is available: {self.requirements} {cur} => {new}
Would you like to upgrade (y/n)? """)
resp = input().lower()
self.builder.out.write("\n")
if resp in ('y', 'yes', 'o', 'oui', 'j', 'ja'): return reqs
return resp in ('y', 'yes', 'o', 'oui', 'j', 'ja')

def create(self, reqs):
self.reqs = reqs
self.requirements = reqs
self.builder.root.mkdir(exist_ok=True)
token = self.env.set(self)
try:
Expand All @@ -143,14 +152,19 @@ def create(self, reqs):
finally:
self.env.reset(token)

def touch(self):
with contextlib.suppress(OSError):
os.utime(self.path / self.requirements_txt, follow_symlinks=False)
with contextlib.suppress(AttributeError): del self.last_used

@contextlib.contextmanager
@staticmethod
def requirements():
def create_requirements():
self = Env.env.get()
reqs = self.path / f'{self.requirements_txt}.tmp'
reqs.write_text(self.reqs)
yield reqs
reqs.rename(self.path / self.requirements_txt)
rpath = self.path / f'{self.requirements_txt}.tmp'
rpath.write_text(self.requirements)
yield rpath
rpath.rename(self.path / self.requirements_txt)

def remove(self):
try:
Expand All @@ -172,18 +186,22 @@ def __init__(self, base, out):
self.out = out

def find(self):
envs = [Env(path, self) for path in self.root.glob(f'{Env.prefix}-*')]
envs.sort(key=lambda e: e.time, reverse=True)
envs = {}
for path in self.root.glob(f'{Env.prefix}-*'):
env = Env(path, self)
envs.setdefault(env.requirements, []).append(env)
for reqs, es in envs.items():
es.sort(key=lambda e: e.last_used, reverse=True)
return envs

def new(self):
return Env(self.root / f'{Env.prefix}-{time.time_ns():024x}', self)

def post_setup(self, ctx):
super().post_setup(ctx)
with Env.requirements() as reqs:
with Env.create_requirements() as rpath:
self.pip(ctx, 'install', '--only-binary=:all:',
'--requirement', reqs)
'--requirement', rpath)

def pip(self, ctx, *args):
subprocess.run((ctx.env_exec_cmd, '-P', '-m', 'pip',
Expand Down
14 changes: 6 additions & 8 deletions tdoc/common/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,14 +316,14 @@ def print_serving(self):

def check_upgrade(self):
try:
upgrades, editable = pip_check_upgrades(self.cfg, __project__)
if editable or __project__ not in upgrades: return
upgrades = pip_check_upgrades(self.cfg, __project__)
if __project__ not in upgrades: return
cur = metadata.version(__project__)
new = upgrades[__project__]
if sys.prefix != sys.base_prefix: # Running in a venv
marker = pathlib.Path(sys.prefix) / 'upgrade.txt'
with contextlib.suppress(Exception):
marker.write_text(f'{__project__}=={new}\n')
marker.write_text(f'{cur} {new}')
msg = (self.cfg.ansi(
"@{LYELLOW}A t-doc upgrade is available:@{NORM} "
"%s @{CYAN}%s@{NORM} => @{CYAN}%s@{NORM}\n"
Expand Down Expand Up @@ -439,14 +439,12 @@ def pip(cfg, *args, json_output=False):


def pip_check_upgrades(cfg, package):
pkgs = pip(cfg, 'list', '--editable', '--format=json', json_output=True)
if any(pkg.name == package for pkg in pkgs): return {}
data = pip(cfg, 'install', '--dry-run', '--upgrade',
'--upgrade-strategy=only-if-needed', '--only-binary=:all:',
'--report=-', '--quiet', package, json_output=True)
upgrades = {pkg.metadata.name: pkg.metadata.version for pkg in data.install}
if package not in upgrades: return {}, False
pkgs = pip(cfg, 'list', '--editable', '--format=json', json_output=True)
editable = any(pkg.name == package for pkg in pkgs)
return upgrades, editable
return {pkg.metadata.name: pkg.metadata.version for pkg in data.install}


if __name__ == '__main__':
Expand Down

0 comments on commit dcc9015

Please sign in to comment.