123 lines
4.5 KiB
Python
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 = ""
|