Files
app/backend/fluksio/flow/connector_store.py
T
stroblmeandClaude Opus 5.5 847abd67d6 Give connectors a store that survives restarts, and a way to clear it
`ConnectorNode.store` keeps short strings per connector package in the
engine's database (`connector_state`, migration f8c3e5a91d27), so the nodes of
one package share them and `fluksio serve` needs nothing new: get, set and
delete, at most 64 kB a value and 256 keys a package. A superuser lists the
keys (never the values) and clears them through `/connectors/{node_type}/state`,
or under Stored state in a connector node's panel. Documented in the connector
contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
2026-10-06 17:19:48 +02:00

140 lines
5.1 KiB
Python

"""What a connector keeps across restarts.
A connector holds connections and handles, and those are meant to go when the
process does. A few things are not: the certificate a device presented on first
contact, a token it paired with. :attr:`ConnectorNode.store` is where those go —
a handful of short strings per connector package, in the engine's own database,
so they survive a restart and need nothing ``fluksio serve`` does not have.
Keyed by the connector's top-level package rather than by node: the nodes of
one Hue bridge share the certificate pinned for it, and every node type a
package installs shares the same keys. A superuser lists and clears them
through ``/connectors/{node_type}/state``.
"""
from __future__ import annotations
import re
from datetime import UTC, datetime
from sqlalchemy import delete, func
from sqlmodel import Session, col, select
from fluksio.models import ConnectorState
#: A value is a short string — a fingerprint, a PEM, a token. Bytes worth
#: keeping belong in the artifact store.
MAX_VALUE_BYTES = 64 * 1024
#: Keys per connector package. Past this a connector is keeping something per
#: reading rather than per device.
MAX_KEYS = 256
#: What a top-level package can be called.
CONNECTOR_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,99}")
#: One URL path segment, so the reset route can name every key.
KEY = re.compile(r"[^/\s]{1,200}")
def namespace_of(cls: type) -> str:
"""The package a connector class keeps its state under."""
return cls.__module__.partition(".")[0]
def _checked(key: str) -> str:
if not KEY.fullmatch(key):
raise ValueError(
f"'{key}' is not a valid key: 1 to 200 characters, no slash or whitespace"
)
return key
class ConnectorStore:
"""Short strings one connector package keeps across restarts.
Every call is a database round trip, so from the event loop it goes
through ``asyncio.to_thread`` like any other blocking I/O.
"""
def __init__(self, connector: str) -> None:
if not CONNECTOR_NAME.fullmatch(connector):
raise ValueError(f"'{connector}' is not a connector package name")
self.connector = connector
def get(self, key: str) -> str | None:
"""The value stored under *key*, or ``None``."""
# Imported here: a connector imports this module, and its tests have
# no business opening the engine's database by importing it.
from fluksio.core.db import engine
with Session(engine) as session:
row = session.get(ConnectorState, (self.connector, _checked(key)))
return None if row is None else row.value
def set(self, key: str, value: str) -> None:
"""Store *value* under *key*, replacing what was there."""
from fluksio.core.db import engine, writing
_checked(key)
if len(value.encode()) > MAX_VALUE_BYTES:
raise ValueError(
f"'{key}' is too large to store: {MAX_VALUE_BYTES} bytes at most"
)
with Session(engine) as session, writing(session):
row = session.get(ConnectorState, (self.connector, key))
if row is None:
count = session.exec(
select(func.count())
.select_from(ConnectorState)
.where(ConnectorState.connector == self.connector)
).one()
if count >= MAX_KEYS:
raise ValueError(
f"{self.connector} already stores {MAX_KEYS} keys, the most "
"a connector may keep"
)
row = ConnectorState(connector=self.connector, key=key, value=value)
else:
row.value = value
row.updated_at = datetime.now(UTC)
session.add(row)
session.commit()
def delete(self, key: str) -> bool:
"""Forget *key*. Whether there was anything to forget."""
from fluksio.core.db import engine
with Session(engine) as session:
result = session.exec(
delete(ConnectorState).where(
col(ConnectorState.connector) == self.connector,
col(ConnectorState.key) == _checked(key),
)
)
session.commit()
return bool(result.rowcount)
def entries(self) -> list[ConnectorState]:
"""Every key this package keeps, in key order."""
from fluksio.core.db import engine
with Session(engine) as session:
return list(
session.exec(
select(ConnectorState)
.where(ConnectorState.connector == self.connector)
.order_by(col(ConnectorState.key))
).all()
)
def clear(self) -> int:
"""Forget every key. How many there were."""
from fluksio.core.db import engine
with Session(engine) as session:
result = session.exec(
delete(ConnectorState).where(
col(ConnectorState.connector) == self.connector
)
)
session.commit()
return int(result.rowcount)