From fdb2a6d6b8cc82b537294baa4d62bb1731a1c451 Mon Sep 17 00:00:00 2001 From: Simon Holesch Date: Fri, 16 Aug 2024 18:28:47 +0200 Subject: [PATCH] Agent: Auto Return Reservations It's easy to forget returning the reservation. Add an auto return timer, so that places are automatically returned after a configurable delay. The default delay is 10 hours. --- doc/reference/import-description.md | 31 ++++++++++++++++++++++ not_my_board/_agent.py | 41 ++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/doc/reference/import-description.md b/doc/reference/import-description.md index b8b6072..6c989e6 100644 --- a/doc/reference/import-description.md +++ b/doc/reference/import-description.md @@ -9,6 +9,37 @@ The *Agent* uses this import description to reserve one of the matching ## Settings +### `auto_return_time` + +**Type:** String \ +**Required:** No \ +**Default:** `"10h"` + +Delay after which the reservation is automatically returned. The timer is reset +after editing the import description with the `not-my-board edit` command. The +time format uses a sequence of positive integers followed by lowercase time +units: + +:::{table} +:align: left + +Unit | Description +---- | ----------- +`w` | weeks +`d` | days +`h` | hours +`m` | minutes +`s` | seconds (optional) +::: + +Units must appear in descending order of significance (e.g. weeks before days). +A delay of `0` disables the auto return. + +Examples: +- `600`: 600 seconds or 10 minutes +- `10m`: 10 minutes +- `1h30m`: 1 hour 30 minutes (90 minutes) + ### `parts` **Type:** Table \ diff --git a/not_my_board/_agent.py b/not_my_board/_agent.py index a2f94a5..58172e3 100644 --- a/not_my_board/_agent.py +++ b/not_my_board/_agent.py @@ -12,7 +12,7 @@ import urllib.parse import weakref from dataclasses import dataclass, field -from typing import Mapping, Tuple +from typing import Mapping, Optional, Tuple import not_my_board._jsonrpc as jsonrpc import not_my_board._models as models @@ -135,15 +135,21 @@ async def serve_forever(self): await self._unix_server.serve_forever() async def _cleanup(self): + # The auto return tasks might currently close tunnels. Cancel auto + # return tasks first. + await util.cancel_tasks( + [r.auto_return_task for r in self._reservations.values()] + ) + coros = [ t.close() for r in self._reservations.values() for t in r.tunnels.values() ] - await util.run_concurrently(*coros) async def reserve(self, name, import_description_toml): parsed = util.toml_loads(import_description_toml) import_description = models.ImportDesc(name=name, **parsed) + auto_return_time = util.parse_time(import_description.auto_return_time) async with self._name_lock(name): if name in self._reservations: @@ -169,8 +175,12 @@ async def reserve(self, name, import_description_toml): desc: desc.tunnel_cls(desc, self._hub_host, self._io) for desc in tunnel_descs_by_id[place_id] } + + coro = self._auto_return(name, auto_return_time) + auto_return_task = asyncio.create_task(coro) + self._reservations[name] = _Reservation( - import_description_toml, place, tunnels + import_description_toml, place, tunnels, auto_return_task ) async def return_reservation(self, name, force=False): @@ -181,6 +191,7 @@ async def return_reservation(self, name, force=False): else: raise RuntimeError(f'Place "{name}" is still attached') await self._hub.return_reservation(reservation.place.id) + await util.cancel_tasks([reservation.auto_return_task]) del self._reservations[name] async def attach(self, name): @@ -206,6 +217,23 @@ async def _detach_reservation(self, reservation): await util.run_concurrently(*coros) reservation.is_attached = False + async def _auto_return(self, name, timeout): # noqa: ASYNC109 + try: + if timeout == 0: + # disable auto return, wait forever + await asyncio.Event().wait() + + await asyncio.sleep(timeout) + logger.info('Auto return timeout: Returning place "%s"', name) + + async with self._reservation(name) as reservation: + if reservation.is_attached: + await self._detach_reservation(reservation) + await self._hub.return_reservation(reservation.place.id) + del self._reservations[name] + except Exception as e: + logger.warning("Auto return failed: %s", e) + @contextlib.asynccontextmanager async def _name_lock(self, name): if name not in self._locks: @@ -253,6 +281,7 @@ async def update_import_description(self, name, import_description_toml): async with self._reservation(name) as reservation: parsed = util.toml_loads(import_description_toml) import_description = models.ImportDesc(name=name, **parsed) + auto_return_time = util.parse_time(import_description.auto_return_time) imported_part_sets = [ (name, _part_to_set(imported_part)) @@ -298,6 +327,11 @@ async def restore_removed(): ] await util.run_concurrently(*coros) + # refresh auto return + await util.cancel_tasks([reservation.auto_return_task]) + coro = self._auto_return(name, auto_return_time) + reservation.auto_return_task = asyncio.create_task(coro) + # everything ok: update reservation reservation.import_description_toml = import_description_toml reservation.tunnels = new_tunnels @@ -496,6 +530,7 @@ class _Reservation: place: models.Place is_attached: bool = field(default=False, init=False) tunnels: Mapping[_TunnelDesc, _Tunnel] + auto_return_task: Optional[asyncio.Task] class ProtocolError(Exception):