Files
app/backend/fluksio/flow/rename.py
T

123 lines
4.5 KiB
Python

"""Renaming a flow's key: the door that keeps the API out while it runs.
A rename moves everything named under a flow's key, in documents, history,
live state and queued work, so nothing may write through the API meanwhile.
`RenameGate` is that door. ``docs/private/id_refac_rename.md`` has the whole
protocol.
"""
from __future__ import annotations
import re
import threading
from collections import Counter
from collections.abc import Callable, Iterable
from starlette.types import Scope
from fluksio.core.config import settings
from fluksio.flow.controller import HOOK_PREFIX
from fluksio.flow.executor import wait_out
#: What reading looks like. Everything else under the API changes something.
_READS = frozenset({"GET", "HEAD", "OPTIONS"})
#: Mutations that cannot reach a flow's namespace, under the API prefix: signing
#: in and accounts, the assistant (whose tool calls come back through the API
#: and are held there), a browser's push subscription, the portal, workers
#: attaching, artifact bytes (which are named by digest, and which a node on a
#: remote worker writes while the rename waits for it), the socket ticket and
#: the rename routes, which keep their own order.
_EXEMPT = re.compile(
r"/(?:login|password-recovery|password-recovery-html-content|reset-password"
r"|oauth|users|ai|cloud|workers|artifacts)(?:/|$)"
r"|/alerts/webpush(?:/|$)"
r"|/flows/ws-ticket$"
r"|/(?:flows|dashboards)/[^/]+/rename(?:/plan)?$"
)
class RenameGate:
"""The door every request passes, which a rename closes.
Open, it counts the mutating API requests it lets in, for as long as each
takes. Closed, it turns new ones away and the rename waits for those
already in, so it starts from a namespace nothing is writing to through
the API. Hooks of the flows being renamed are turned away too, since their
nodes are torn down meanwhile. Reads, websockets and the exempt routes
pass either way, uncounted.
Thread-safe: requests pass on the event loop and `close` waits on a
thread, as the engine holds do.
"""
def __init__(self) -> None:
self._lock = threading.Condition()
self._admitted: Counter[str] = Counter()
self._closed = False
self._affected: frozenset[str] = frozenset()
#: The rename that closed it, for whoever is turned away.
self.op = ""
@property
def closed(self) -> bool:
with self._lock:
return self._closed
def admit(self, scope: Scope) -> tuple[bool, str]:
"""Whether *scope* may pass, and the name it is counted under until
`leave`, empty when it is not counted."""
path: str = scope["path"]
with self._lock:
if path.startswith(f"{HOOK_PREFIX}/"):
flow_id = path[len(HOOK_PREFIX) + 1 :].split("/", 1)[0]
return not (self._closed and flow_id in self._affected), ""
api = settings.API_V1_STR
if (
scope["type"] != "http"
or scope["method"] in _READS
or not path.startswith(f"{api}/")
or _EXEMPT.match(path, len(api))
):
return True, ""
if self._closed:
return False, ""
name = f"{scope['method']} {path}"
self._admitted[name] += 1
return True, name
def leave(self, name: str) -> None:
"""A request counted under *name* is done."""
with self._lock:
self._admitted -= Counter([name])
self._lock.notify_all()
def close(
self,
op: str,
affected: Iterable[str],
abort: threading.Event,
report: Callable[[list[str]], None] | None = None,
) -> bool:
"""Turn mutations and the hooks of the *affected* flow ids away, then
wait for the requests already in to finish. Blocking.
Without a limit: ``report`` hears the requests still running each time
they change, and setting ``abort`` gives up, opens the gate again and
answers False. True means closed until `open`.
"""
with self._lock:
self._closed = True
self._affected = frozenset(affected)
self.op = op
if wait_out(self._lock, lambda: sorted(self._admitted), abort, report):
return True
self.open()
return False
def open(self) -> None:
with self._lock:
self._closed = False
self._affected = frozenset()
self.op = ""