Files
devplacepy/devplacepy/database/nodes.py
T
retoorandClaude Sonnet 5.5 02ab747cd0
DevPlace CI / test (push) Failing after 44m45s
Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
Continuous Delivery could never be turned off once a crop was planted. Add a
one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool
game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant,
autoreplant claim or Refactor. Autoreplant now also respects crop locks through
the shared _crop_lock predicate. Plan and measurements in happyplanting.md.

Also includes the pending community sync, node network and related work in the
tree; the full suite passed (4999) with all of it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:23:43 +02:00

1167 lines
38 KiB
Python

# retoor <retoor@molodetz.nl>
import hashlib
import hmac
import json
import re
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from devplacepy.config import SECRET_KEY
from devplacepy.services.nodes.spec import DEVICE_KINDS, SPEC
from .core import _in_clause, _now_iso, db, get_table
from .soft_delete import soft_delete
LIMITS = SPEC.limits
SECRET_PREFIX = "dpn_"
SECRET_PREFIX_LENGTH = 12
SECRET_BYTES = 32
LABEL_MAX = 60
EVENT_DETAIL_MAX = 500
MEGABYTE = 1024 * 1024
BATCH_SIZE = 500
COUNTER_ROW_LIMIT = 500
HOURS_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
PLATFORMS = tuple(sorted({item.adapter for item in SPEC.destinations}))
FLAG_OPEN = "open"
FLAG_RESOLVED = "resolved"
STATUS_ACTIVE = "active"
STATUS_SUSPENDED = "suspended"
STATUS_REVOKED = "revoked"
DEFAULT_SETTINGS = {
"enabled": False,
"bandwidth_mb_day": LIMITS.default_donor_mb_per_day,
"max_concurrent": LIMITS.max_concurrent_per_node,
"active_from": "",
"active_to": "",
"timezone": "UTC",
"allowed_platforms": list(PLATFORMS),
"pause_on_battery": True,
"public_display": False,
}
class NodeLimitError(ValueError):
pass
def _uid() -> str:
from devplacepy.utils import generate_uid
return generate_uid()
def _day(moment: datetime | None = None) -> str:
return (moment or datetime.now(timezone.utc)).date().isoformat()
def hash_ip(address: str) -> str:
if not address:
return ""
return hmac.new(
SECRET_KEY.encode(), address.encode(), hashlib.sha256
).hexdigest()[:32]
def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode()).hexdigest()
def new_secret() -> str:
return SECRET_PREFIX + secrets.token_urlsafe(SECRET_BYTES)
def _execute(sql: str, params: dict) -> int:
with db:
return db.executable.execute(text(sql), params).rowcount
def create_device(
user_uid: str,
label: str,
kind: str,
*,
platform: str = "",
client_version: str = "",
) -> tuple[dict, str]:
if kind not in DEVICE_KINDS:
raise NodeLimitError("Unknown device kind")
secret = new_secret()
uid = _uid()
now = _now_iso()
created = _execute(
"INSERT INTO node_devices (uid, user_uid, label, kind, secret_hash, "
"secret_prefix, platform, client_version, protocol_version, status, "
"suspended_until, suspend_reason, country, asn, asn_class, ip_hash, "
"last_seen_at, created_at, updated_at) "
"SELECT :uid, :user_uid, :label, :kind, :hash, :prefix, :platform, "
":version, 0, 'active', '', '', '', '', 'unknown', '', '', :now, :now "
"WHERE (SELECT COUNT(*) FROM node_devices "
"WHERE user_uid = :user_uid AND deleted_at IS NULL) < :max_devices",
{
"uid": uid,
"user_uid": user_uid,
"label": (label or "").strip()[:LABEL_MAX],
"kind": kind,
"hash": _hash_secret(secret),
"prefix": secret[:SECRET_PREFIX_LENGTH],
"platform": (platform or "")[:40],
"version": (client_version or "")[:40],
"now": now,
"max_devices": LIMITS.max_devices_per_user,
},
)
if created != 1:
raise NodeLimitError(
f"A member can register at most {LIMITS.max_devices_per_user} devices"
)
return get_device(uid), secret
def get_device(device_uid: str) -> dict | None:
return get_table("node_devices").find_one(uid=device_uid, deleted_at=None)
def _batches(values) -> list[list[str]]:
unique = list(dict.fromkeys(value for value in values if value))
return [unique[i : i + BATCH_SIZE] for i in range(0, len(unique), BATCH_SIZE)]
def get_devices_by_uids(device_uids) -> dict[str, dict]:
found: dict[str, dict] = {}
for batch in _batches(device_uids):
placeholders, params = _in_clause(batch)
for row in db.query(
f"SELECT * FROM node_devices WHERE uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
):
found[row["uid"]] = dict(row)
return found
def get_enabled_by_users(user_uids) -> dict[str, bool]:
found: dict[str, bool] = {}
for batch in _batches(user_uids):
placeholders, params = _in_clause(batch)
for row in db.query(
f"SELECT user_uid, enabled FROM node_settings WHERE user_uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
):
found[row["user_uid"]] = bool(row["enabled"])
return found
def list_devices(user_uid: str) -> list[dict]:
return list(
get_table("node_devices").find(
user_uid=user_uid, deleted_at=None, order_by=["created_at"]
)
)
def count_devices(user_uid: str) -> int:
return get_table("node_devices").count(user_uid=user_uid, deleted_at=None)
def find_device_by_secret(secret: str) -> dict | None:
if not secret or not secret.startswith(SECRET_PREFIX):
return None
digest = _hash_secret(secret)
for row in get_table("node_devices").find(
secret_prefix=secret[:SECRET_PREFIX_LENGTH], deleted_at=None
):
if hmac.compare_digest(row.get("secret_hash") or "", digest):
return row
return None
def rotate_device_secret(device_uid: str, user_uid: str) -> str | None:
secret = new_secret()
changed = _execute(
"UPDATE node_devices SET secret_hash = :hash, secret_prefix = :prefix, "
"updated_at = :now WHERE uid = :uid AND user_uid = :user_uid "
"AND deleted_at IS NULL",
{
"hash": _hash_secret(secret),
"prefix": secret[:SECRET_PREFIX_LENGTH],
"now": _now_iso(),
"uid": device_uid,
"user_uid": user_uid,
},
)
return secret if changed == 1 else None
def revoke_device(device_uid: str, actor_uid: str) -> int:
get_table("node_devices").update(
{"uid": device_uid, "status": STATUS_REVOKED, "updated_at": _now_iso()},
["uid"],
)
return soft_delete("node_devices", actor_uid, uid=device_uid)
def suspend_device(device_uid: str, reason: str, minutes: int) -> bool:
until = (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat()
return (
_execute(
"UPDATE node_devices SET status = 'suspended', suspended_until = :until, "
"suspend_reason = :reason, updated_at = :now "
"WHERE uid = :uid AND deleted_at IS NULL AND status != 'revoked'",
{
"until": until,
"reason": (reason or "")[:EVENT_DETAIL_MAX],
"now": _now_iso(),
"uid": device_uid,
},
)
== 1
)
def unsuspend_device(device_uid: str) -> bool:
return (
_execute(
"UPDATE node_devices SET status = 'active', suspended_until = '', "
"suspend_reason = '', updated_at = :now "
"WHERE uid = :uid AND deleted_at IS NULL AND status = 'suspended'",
{"now": _now_iso(), "uid": device_uid},
)
== 1
)
def effective_status(device: dict, moment: datetime | None = None) -> str:
status = device.get("status") or STATUS_ACTIVE
if status != STATUS_SUSPENDED:
return status
until = (device.get("suspended_until") or "").strip()
try:
expiry = datetime.fromisoformat(until)
except ValueError:
return STATUS_SUSPENDED
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
now = moment or datetime.now(timezone.utc)
return STATUS_ACTIVE if expiry <= now else STATUS_SUSPENDED
def record_device_connection(
device_uid: str,
*,
platform: str = "",
client_version: str = "",
protocol_version: int = 0,
country: str = "",
asn: str = "",
asn_class: str = "unknown",
ip_hash: str = "",
) -> None:
get_table("node_devices").update(
{
"uid": device_uid,
"platform": platform[:40],
"client_version": client_version[:40],
"protocol_version": int(protocol_version),
"country": country[:2].upper(),
"asn": asn[:20],
"asn_class": asn_class,
"ip_hash": ip_hash,
"last_seen_at": _now_iso(),
},
["uid"],
)
def _clamped(value, low: int, high: int, default: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return default
return max(low, min(high, number))
def _hours(value) -> str:
value = (value or "").strip()
return value if HOURS_PATTERN.match(value) else ""
def _platforms(value) -> list[str]:
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
return list(PLATFORMS)
if not isinstance(value, (list, tuple)):
return list(PLATFORMS)
return [item for item in PLATFORMS if item in set(value)]
def normalise_settings(values: dict) -> dict:
return {
"enabled": bool(values.get("enabled")),
"bandwidth_mb_day": _clamped(
values.get("bandwidth_mb_day"),
1,
LIMITS.max_donor_mb_per_day,
LIMITS.default_donor_mb_per_day,
),
"max_concurrent": _clamped(
values.get("max_concurrent"),
1,
LIMITS.max_concurrent_per_node,
LIMITS.max_concurrent_per_node,
),
"active_from": _hours(values.get("active_from")),
"active_to": _hours(values.get("active_to")),
"timezone": (values.get("timezone") or "UTC").strip()[:64] or "UTC",
"allowed_platforms": _platforms(values.get("allowed_platforms")),
"pause_on_battery": bool(values.get("pause_on_battery")),
"public_display": bool(values.get("public_display")),
}
def get_node_settings(user_uid: str) -> dict:
row = get_table("node_settings").find_one(user_uid=user_uid, deleted_at=None)
if not row:
return dict(DEFAULT_SETTINGS)
return normalise_settings(row)
def save_node_settings(user_uid: str, values: dict) -> dict:
merged = {**get_node_settings(user_uid), **values}
clean = normalise_settings(merged)
now = _now_iso()
_execute(
"INSERT INTO node_settings (uid, user_uid, enabled, bandwidth_mb_day, "
"max_concurrent, active_from, active_to, timezone, allowed_platforms, "
"pause_on_battery, public_display, created_at, updated_at) "
"VALUES (:uid, :user_uid, :enabled, :mb, :concurrent, :hours_from, "
":hours_to, :zone, :platforms, :battery, :public, :now, :now) "
"ON CONFLICT(user_uid) DO UPDATE SET enabled = excluded.enabled, "
"bandwidth_mb_day = excluded.bandwidth_mb_day, "
"max_concurrent = excluded.max_concurrent, "
"active_from = excluded.active_from, active_to = excluded.active_to, "
"timezone = excluded.timezone, "
"allowed_platforms = excluded.allowed_platforms, "
"pause_on_battery = excluded.pause_on_battery, "
"public_display = excluded.public_display, "
"updated_at = excluded.updated_at",
{
"uid": _uid(),
"user_uid": user_uid,
"enabled": int(clean["enabled"]),
"mb": clean["bandwidth_mb_day"],
"concurrent": clean["max_concurrent"],
"hours_from": clean["active_from"],
"hours_to": clean["active_to"],
"zone": clean["timezone"],
"platforms": json.dumps(clean["allowed_platforms"]),
"battery": int(clean["pause_on_battery"]),
"public": int(clean["public_display"]),
"now": now,
},
)
return clean
def daily_cap_bytes(user_uid: str) -> int:
return get_node_settings(user_uid)["bandwidth_mb_day"] * MEGABYTE
def record_job(
device_uid: str,
user_uid: str,
*,
ok: bool,
bytes_up: int = 0,
bytes_down: int = 0,
cache_hit: bool = False,
cap_bytes: int | None = None,
moment: datetime | None = None,
) -> bool:
now = (moment or datetime.now(timezone.utc)).isoformat()
day = _day(moment)
up = max(0, int(bytes_up))
down = max(0, int(bytes_down))
cap = daily_cap_bytes(user_uid) if cap_bytes is None else int(cap_bytes)
with db:
db.executable.execute(
text(
"INSERT OR IGNORE INTO node_usage_daily (uid, device_uid, user_uid, "
"day, requests_ok, requests_failed, bytes_up, bytes_down, "
"cache_hits_served, integrity_failures, created_at, updated_at) "
"VALUES (:uid, :device, :user, :day, 0, 0, 0, 0, 0, 0, :now, :now)"
),
{"uid": _uid(), "device": device_uid, "user": user_uid, "day": day, "now": now},
)
if not ok:
db.executable.execute(
text(
"UPDATE node_usage_daily SET "
"requests_failed = COALESCE(requests_failed, 0) + 1, "
"updated_at = :now WHERE device_uid = :device AND day = :day"
),
{"now": now, "device": device_uid, "day": day},
)
return True
counted = db.executable.execute(
text(
"UPDATE node_usage_daily SET "
"requests_ok = COALESCE(requests_ok, 0) + 1, "
"bytes_up = COALESCE(bytes_up, 0) + :up, "
"bytes_down = COALESCE(bytes_down, 0) + :down, "
"cache_hits_served = COALESCE(cache_hits_served, 0) + :hit, "
"updated_at = :now "
"WHERE device_uid = :device AND day = :day "
"AND COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0) + :up + :down "
"<= :cap"
),
{
"up": up,
"down": down,
"hit": 1 if cache_hit else 0,
"now": now,
"device": device_uid,
"day": day,
"cap": cap,
},
).rowcount
if counted != 1:
return False
db.executable.execute(
text(
"INSERT INTO node_donors (uid, user_uid, requests_ok, bytes_total, "
"first_donated_at, last_donated_at, xp_awarded_today, xp_day, "
"created_at, updated_at) "
"VALUES (:uid, :user, 1, :bytes, :now, :now, 0, '', :now, :now) "
"ON CONFLICT(user_uid) DO UPDATE SET "
"requests_ok = COALESCE(requests_ok, 0) + 1, "
"bytes_total = COALESCE(bytes_total, 0) + :bytes, "
"first_donated_at = CASE WHEN COALESCE(first_donated_at, '') = '' "
"THEN :now ELSE first_donated_at END, "
"last_donated_at = :now, updated_at = :now"
),
{"uid": _uid(), "user": user_uid, "bytes": up + down, "now": now},
)
return True
def record_integrity_failure(device_uid: str, user_uid: str) -> None:
now = _now_iso()
with db:
db.executable.execute(
text(
"INSERT OR IGNORE INTO node_usage_daily (uid, device_uid, user_uid, "
"day, requests_ok, requests_failed, bytes_up, bytes_down, "
"cache_hits_served, integrity_failures, created_at, updated_at) "
"VALUES (:uid, :device, :user, :day, 0, 0, 0, 0, 0, 0, :now, :now)"
),
{"uid": _uid(), "device": device_uid, "user": user_uid, "day": _day(), "now": now},
)
db.executable.execute(
text(
"UPDATE node_usage_daily SET "
"integrity_failures = COALESCE(integrity_failures, 0) + 1, "
"updated_at = :now WHERE device_uid = :device AND day = :day"
),
{"now": now, "device": device_uid, "day": _day()},
)
def claim_xp(user_uid: str, amount: int, *, moment: datetime | None = None) -> bool:
if amount <= 0:
return False
day = _day(moment)
return (
_execute(
"UPDATE node_donors SET "
"xp_awarded_today = CASE WHEN COALESCE(xp_day, '') = :day "
"THEN COALESCE(xp_awarded_today, 0) + :amount ELSE :amount END, "
"xp_day = :day WHERE user_uid = :user AND deleted_at IS NULL AND "
"(CASE WHEN COALESCE(xp_day, '') = :day "
"THEN COALESCE(xp_awarded_today, 0) ELSE 0 END) + :amount <= :cap",
{"day": day, "amount": amount, "user": user_uid, "cap": LIMITS.xp_daily_cap},
)
== 1
)
def get_donor(user_uid: str) -> dict:
row = get_table("node_donors").find_one(user_uid=user_uid, deleted_at=None)
return {
"requests_ok": int((row or {}).get("requests_ok") or 0),
"bytes_total": int((row or {}).get("bytes_total") or 0),
"first_donated_at": (row or {}).get("first_donated_at") or "",
"last_donated_at": (row or {}).get("last_donated_at") or "",
}
def usage_since(user_uid: str, since_day: str) -> dict:
rows = db.query(
"SELECT COALESCE(SUM(requests_ok), 0) AS requests_ok, "
"COALESCE(SUM(requests_failed), 0) AS requests_failed, "
"COALESCE(SUM(bytes_up), 0) AS bytes_up, "
"COALESCE(SUM(bytes_down), 0) AS bytes_down "
"FROM node_usage_daily WHERE user_uid = :user AND day >= :since "
"AND deleted_at IS NULL",
user=user_uid,
since=since_day,
)
return dict(next(iter(rows)))
def usage_today(device_uid: str) -> dict:
row = get_table("node_usage_daily").find_one(
device_uid=device_uid, day=_day(), deleted_at=None
)
row = row or {}
return {
key: int(row.get(key) or 0)
for key in (
"requests_ok",
"requests_failed",
"bytes_up",
"bytes_down",
"cache_hits_served",
"integrity_failures",
)
}
def community_stats() -> dict:
rows = db.query(
"SELECT COALESCE(SUM(requests_ok), 0) AS requests, "
"COALESCE(SUM(bytes_total), 0) AS bytes, COUNT(*) AS donors "
"FROM node_donors WHERE deleted_at IS NULL AND COALESCE(requests_ok, 0) > 0"
)
row = next(iter(rows))
return {
"requests": int(row["requests"]),
"bytes": int(row["bytes"]),
"donors": int(row["donors"]),
}
def add_event(
kind: str, *, device_uid: str = "", user_uid: str = "", detail: str = ""
) -> None:
get_table("node_events").insert(
{
"uid": _uid(),
"device_uid": device_uid,
"user_uid": user_uid,
"kind": kind[:40],
"detail": (detail or "")[:EVENT_DETAIL_MAX],
"created_at": _now_iso(),
}
)
def raise_flag(kind: str, *, device_uid: str = "", user_uid: str = "", detail: str = "") -> dict:
table = get_table("node_flags")
existing = table.find_one(
kind=kind, device_uid=device_uid, status=FLAG_OPEN, deleted_at=None
)
if existing:
return existing
uid = _uid()
now = _now_iso()
table.insert(
{
"uid": uid,
"device_uid": device_uid,
"user_uid": user_uid,
"kind": kind[:40],
"detail": (detail or "")[:EVENT_DETAIL_MAX],
"status": FLAG_OPEN,
"resolved_by": "",
"resolved_at": "",
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def list_flags(status: str = FLAG_OPEN) -> list[dict]:
return list(
get_table("node_flags").find(
status=status, deleted_at=None, order_by=["-created_at"]
)
)
def resolve_flag(flag_uid: str, actor_uid: str) -> bool:
return (
_execute(
"UPDATE node_flags SET status = 'resolved', resolved_by = :actor, "
"resolved_at = :now, updated_at = :now "
"WHERE uid = :uid AND status = 'open' AND deleted_at IS NULL",
{"actor": actor_uid, "now": _now_iso(), "uid": flag_uid},
)
== 1
)
def prune_usage_daily(*, dry_run: bool = False, moment: datetime | None = None) -> int:
cutoff = _day((moment or datetime.now(timezone.utc)) - timedelta(days=LIMITS.usage_daily_retention_days))
count = next(
iter(
db.query(
"SELECT COUNT(*) AS c FROM node_usage_daily WHERE day < :cutoff",
cutoff=cutoff,
)
)
)["c"]
callers = next(
iter(
db.query(
"SELECT COUNT(*) AS c FROM node_caller_daily WHERE day < :cutoff",
cutoff=cutoff,
)
)
)["c"]
if not dry_run:
if count:
_execute("DELETE FROM node_usage_daily WHERE day < :cutoff", {"cutoff": cutoff})
if callers:
_execute("DELETE FROM node_caller_daily WHERE day < :cutoff", {"cutoff": cutoff})
return count + callers
def prune_events(*, dry_run: bool = False, moment: datetime | None = None) -> int:
cutoff = (
(moment or datetime.now(timezone.utc))
- timedelta(days=LIMITS.event_retention_days)
).isoformat()
aged = next(
iter(
db.query(
"SELECT COUNT(*) AS c FROM node_events WHERE created_at < :cutoff",
cutoff=cutoff,
)
)
)["c"]
total = get_table("node_events").count()
excess = max(0, total - aged - LIMITS.event_max_rows)
if not dry_run:
if aged:
_execute("DELETE FROM node_events WHERE created_at < :cutoff", {"cutoff": cutoff})
if excess:
_execute(
"DELETE FROM node_events WHERE id IN "
"(SELECT id FROM node_events ORDER BY created_at, id LIMIT :n)",
{"n": excess},
)
return aged + excess
def prune_requests(*, dry_run: bool = False, moment: datetime | None = None) -> int:
cutoff = (
(moment or datetime.now(timezone.utc))
- timedelta(seconds=LIMITS.request_retention_seconds)
).isoformat()
count = next(
iter(
db.query(
"SELECT COUNT(*) AS c FROM node_requests WHERE created_at < :cutoff",
cutoff=cutoff,
)
)
)["c"]
if not dry_run and count:
_execute("DELETE FROM node_requests WHERE created_at < :cutoff", {"cutoff": cutoff})
return count
def clear_ephemeral(*, dry_run: bool = True) -> dict:
counts = {}
for table in ("node_requests", "node_events"):
counts[table] = next(iter(db.query(f"SELECT COUNT(*) AS c FROM {table}")))["c"]
if not dry_run and counts[table]:
_execute(f"DELETE FROM {table}", {})
return counts
class QueueFull(NodeLimitError):
pass
REQUEST_OPEN = ("queued", "assigned")
REQUEST_COLUMNS_PUBLIC = (
"uid",
"status",
"result_status",
"result_bytes",
"result_headers",
"truncated",
"error",
)
def enqueue_request(
caller_kind: str,
caller_id: str,
url: str,
method: str,
adapter: str,
mode: str,
*,
shadow_of: str = "",
exclude_device: str = "",
) -> str:
uid = _uid()
created = _execute(
"INSERT INTO node_requests (uid, caller_kind, caller_id, url, method, adapter, "
"mode, status, device_uid, result_status, result_bytes, result_body, "
"result_headers, truncated, error, device_asn_class, shadow_of, exclude_device, "
"created_at, assigned_at, finished_at) "
"SELECT :uid, :kind, :caller, :url, :method, :adapter, :mode, 'queued', '', 0, 0, "
"'', '', 0, '', '', :shadow_of, :exclude_device, :now, '', '' "
"WHERE (SELECT COUNT(*) FROM node_requests WHERE status IN ('queued','assigned')) "
"< :max_depth",
{
"uid": uid,
"kind": caller_kind,
"caller": caller_id,
"url": url,
"method": method,
"adapter": adapter,
"mode": mode,
"shadow_of": shadow_of,
"exclude_device": exclude_device,
"now": _now_iso(),
"max_depth": LIMITS.max_queue_depth,
},
)
if created != 1:
raise QueueFull("The node queue is full")
return uid
def get_request(request_uid: str) -> dict | None:
return get_table("node_requests").find_one(uid=request_uid)
def list_queued(limit: int) -> list[dict]:
return [
dict(row)
for row in db.query(
"SELECT * FROM node_requests WHERE status = 'queued' "
"ORDER BY created_at, id LIMIT :n",
n=int(limit),
)
]
def count_open_requests() -> int:
return next(
iter(
db.query(
"SELECT COUNT(*) AS c FROM node_requests WHERE status IN ('queued','assigned')"
)
)
)["c"]
def claim_request(request_uid: str, device_uid: str, asn_class: str = "") -> bool:
return (
_execute(
"UPDATE node_requests SET status = 'assigned', device_uid = :device, "
"device_asn_class = :asn_class, assigned_at = :now "
"WHERE uid = :uid AND status = 'queued'",
{
"device": device_uid,
"asn_class": asn_class,
"now": _now_iso(),
"uid": request_uid,
},
)
== 1
)
def finish_request(
request_uid: str,
*,
result_status: int,
result_headers: str,
result_body: str,
result_bytes: int,
truncated: bool,
) -> bool:
return (
_execute(
"UPDATE node_requests SET status = 'done', result_status = :status, "
"result_headers = :headers, result_body = :body, result_bytes = :bytes, "
"truncated = :truncated, finished_at = :now "
"WHERE uid = :uid AND status = 'assigned'",
{
"status": int(result_status),
"headers": result_headers,
"body": result_body,
"bytes": int(result_bytes),
"truncated": 1 if truncated else 0,
"now": _now_iso(),
"uid": request_uid,
},
)
== 1
)
def set_request_transport(request_uid: str, transport: str) -> bool:
return (
_execute(
"UPDATE node_requests SET mode = :mode WHERE uid = :uid AND status = 'assigned'",
{"mode": transport, "uid": request_uid},
)
== 1
)
def fail_request(request_uid: str, error: str, *, expired: bool = False) -> bool:
return (
_execute(
"UPDATE node_requests SET status = :status, error = :error, finished_at = :now "
"WHERE uid = :uid AND status IN ('queued','assigned')",
{
"status": "expired" if expired else "failed",
"error": (error or "")[:EVENT_DETAIL_MAX],
"now": _now_iso(),
"uid": request_uid,
},
)
== 1
)
def expire_requests(moment: datetime | None = None) -> dict[str, int]:
now = moment or datetime.now(timezone.utc)
queued_cutoff = (now - timedelta(seconds=LIMITS.queue_max_wait_seconds)).isoformat()
assigned_cutoff = (
now - timedelta(seconds=LIMITS.job_timeout_seconds + LIMITS.job_grace_seconds)
).isoformat()
finished = now.isoformat()
queued = _execute(
"UPDATE node_requests SET status = 'expired', error = 'no_node', finished_at = :now "
"WHERE status = 'queued' AND created_at < :cutoff",
{"now": finished, "cutoff": queued_cutoff},
)
assigned = _execute(
"UPDATE node_requests SET status = 'failed', error = 'timeout', finished_at = :now "
"WHERE status = 'assigned' AND assigned_at < :cutoff",
{"now": finished, "cutoff": assigned_cutoff},
)
return {"queued": queued, "assigned": assigned}
def reserve_caller_request(
caller_kind: str, caller_id: str, limit: int, *, moment: datetime | None = None
) -> bool:
if limit <= 0 or not caller_id:
return False
now = (moment or datetime.now(timezone.utc)).isoformat()
day = _day(moment)
with db:
db.executable.execute(
text(
"INSERT OR IGNORE INTO node_caller_daily (uid, caller_kind, caller_id, day, "
"requests, created_at, updated_at) VALUES (:uid, :kind, :caller, :day, 0, :now, :now)"
),
{"uid": _uid(), "kind": caller_kind, "caller": caller_id, "day": day, "now": now},
)
counted = db.executable.execute(
text(
"UPDATE node_caller_daily SET requests = COALESCE(requests, 0) + 1, "
"updated_at = :now WHERE caller_kind = :kind AND caller_id = :caller "
"AND day = :day AND deleted_at IS NULL AND COALESCE(requests, 0) < :limit"
),
{"now": now, "kind": caller_kind, "caller": caller_id, "day": day, "limit": int(limit)},
).rowcount
return counted == 1
def caller_requests_today(caller_kind: str, caller_id: str) -> int:
row = get_table("node_caller_daily").find_one(
caller_kind=caller_kind, caller_id=caller_id, day=_day(), deleted_at=None
)
return int((row or {}).get("requests") or 0)
def get_settings_by_users(user_uids) -> dict[str, dict]:
found: dict[str, dict] = {}
for batch in _batches(user_uids):
placeholders, params = _in_clause(batch)
for row in db.query(
f"SELECT * FROM node_settings WHERE user_uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
):
found[row["user_uid"]] = normalise_settings(dict(row))
return found
def donation_days(user_uid: str) -> int:
rows = db.query(
"SELECT COUNT(DISTINCT day) AS days FROM node_usage_daily "
"WHERE user_uid = :user AND COALESCE(requests_ok, 0) > 0 AND deleted_at IS NULL",
user=user_uid,
)
return int(next(iter(rows))["days"] or 0)
def public_stats() -> dict:
row = next(
iter(
db.query(
"SELECT COALESCE(SUM(requests_ok), 0) AS requests, "
"COALESCE(SUM(bytes_total), 0) AS bytes, COUNT(*) AS donors "
"FROM node_donors WHERE deleted_at IS NULL AND COALESCE(requests_ok, 0) > 0"
)
)
)
return {
"requests": int(row["requests"] or 0),
"bytes": int(row["bytes"] or 0),
"donors": int(row["donors"] or 0),
}
def top_donors(limit: int = 10) -> list[dict]:
limit = max(1, min(int(limit), 50))
rows = db.query(
"SELECT d.user_uid AS user_uid, d.requests_ok AS requests_ok, d.bytes_total AS bytes_total "
"FROM node_donors d JOIN node_settings s ON s.user_uid = d.user_uid "
"WHERE d.deleted_at IS NULL AND s.deleted_at IS NULL AND COALESCE(s.public_display, 0) = 1 "
"AND COALESCE(d.requests_ok, 0) > 0 ORDER BY d.bytes_total DESC LIMIT :limit",
limit=limit,
)
return [dict(row) for row in rows]
ADMIN_LIST_LIMIT = 100
ADMIN_BREAKDOWN_LIMIT = 50
DEVICE_BREAKDOWN_COLUMNS = ("status", "kind", "asn_class", "country")
REQUEST_STATUSES = ("queued", "assigned", "done", "failed", "expired")
def device_breakdown(column: str) -> dict[str, int]:
if column not in DEVICE_BREAKDOWN_COLUMNS:
raise ValueError("Unknown device breakdown column")
rows = db.query(
f"SELECT COALESCE(NULLIF({column}, ''), 'unknown') AS bucket, COUNT(*) AS total "
"FROM node_devices WHERE deleted_at IS NULL "
"GROUP BY bucket ORDER BY total DESC, bucket LIMIT :n",
n=ADMIN_BREAKDOWN_LIMIT,
)
return {row["bucket"]: int(row["total"]) for row in rows}
def request_status_counts() -> dict[str, int]:
counts = {status: 0 for status in REQUEST_STATUSES}
for row in db.query("SELECT status, COUNT(*) AS total FROM node_requests GROUP BY status"):
counts[row["status"]] = int(row["total"])
return counts
def usage_day_totals(moment: datetime | None = None) -> dict[str, int]:
row = next(
iter(
db.query(
"SELECT COALESCE(SUM(requests_ok), 0) AS requests_ok, "
"COALESCE(SUM(requests_failed), 0) AS requests_failed, "
"COALESCE(SUM(bytes_up), 0) AS bytes_up, "
"COALESCE(SUM(bytes_down), 0) AS bytes_down, "
"COALESCE(SUM(cache_hits_served), 0) AS cache_hits_served, "
"COALESCE(SUM(integrity_failures), 0) AS integrity_failures "
"FROM node_usage_daily WHERE day = :day AND deleted_at IS NULL",
day=_day(moment),
)
)
)
return {key: int(value or 0) for key, value in dict(row).items()}
def recent_devices(limit: int = ADMIN_LIST_LIMIT) -> list[dict]:
limit = max(1, min(int(limit), ADMIN_LIST_LIMIT))
return [
dict(row)
for row in db.query(
"SELECT * FROM node_devices WHERE deleted_at IS NULL "
"ORDER BY COALESCE(NULLIF(last_seen_at, ''), created_at) DESC, id DESC LIMIT :n",
n=limit,
)
]
def set_class_override(device_uid: str, value: str) -> bool:
return (
_execute(
"UPDATE node_devices SET class_override = :value, updated_at = :now "
"WHERE uid = :uid AND deleted_at IS NULL",
{"value": value, "now": _now_iso(), "uid": device_uid},
)
== 1
)
def touch_devices(device_uids, moment: datetime | None = None) -> int:
stamp = (moment or datetime.now(timezone.utc)).isoformat()
touched = 0
for chunk in _batches(device_uids):
placeholders, params = _in_clause(chunk)
touched += _execute(
f"UPDATE node_devices SET last_seen_at = :now WHERE uid IN ({placeholders})",
{"now": stamp, **params},
)
return touched
def flush_counters(deltas: dict[str, int]) -> int:
rows = {name: int(value) for name, value in deltas.items() if int(value) > 0}
if not rows:
return 0
now = _now_iso()
with db:
for name, value in rows.items():
db.executable.execute(
text(
"INSERT INTO node_counters (name, value, updated_at) VALUES (:name, :value, :now) "
"ON CONFLICT(name) DO UPDATE SET value = COALESCE(value, 0) + excluded.value, "
"updated_at = excluded.updated_at"
),
{"name": name, "value": value, "now": now},
)
return len(rows)
def lifetime_counters(prefix: str = "") -> dict[str, int]:
rows = db.query(
"SELECT name, COALESCE(value, 0) AS value FROM node_counters "
"WHERE name LIKE :prefix ESCAPE '\\' ORDER BY name LIMIT :n",
prefix=prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%",
n=COUNTER_ROW_LIMIT,
)
return {row["name"]: int(row["value"]) for row in rows}
def save_circuits(rows: list[dict]) -> int:
now = _now_iso()
with db:
db.executable.execute(text("DELETE FROM node_circuits"))
for row in rows[: LIMITS.circuit_snapshot_max]:
db.executable.execute(
text(
"INSERT INTO node_circuits (key, failures, state, opened_at, updated_at) "
"VALUES (:key, :failures, :state, :opened_at, :now)"
),
{
"key": row["key"],
"failures": int(row["failures"]),
"state": row["state"],
"opened_at": float(row["opened_at"]),
"now": now,
},
)
return min(len(rows), LIMITS.circuit_snapshot_max)
def load_circuits() -> list[dict]:
return [
dict(row)
for row in db.query(
"SELECT key, failures, state, opened_at FROM node_circuits LIMIT :n",
n=LIMITS.circuit_snapshot_max,
)
]
def cache_get(key: str, moment: datetime | None = None) -> dict | None:
now = (moment or datetime.now(timezone.utc)).isoformat()
rows = list(
db.query(
"SELECT status, headers, body, stored_at FROM node_cache "
"WHERE key = :key AND expires_at > :now LIMIT 1",
key=key,
now=now,
)
)
return dict(rows[0]) if rows else None
def cache_put(
key: str,
method: str,
url: str,
status: int,
headers: str,
body: str,
size: int,
ttl_seconds: int,
moment: datetime | None = None,
) -> bool:
if ttl_seconds <= 0:
return False
now = moment or datetime.now(timezone.utc)
return (
_execute(
"INSERT INTO node_cache (key, method, url, status, headers, body, size, stored_at, expires_at) "
"VALUES (:key, :method, :url, :status, :headers, :body, :size, :stored, :expires) "
"ON CONFLICT(key) DO UPDATE SET status = excluded.status, headers = excluded.headers, "
"body = excluded.body, size = excluded.size, stored_at = excluded.stored_at, "
"expires_at = excluded.expires_at",
{
"key": key,
"method": method,
"url": url[: LIMITS.url_max_chars],
"status": int(status),
"headers": headers,
"body": body,
"size": int(size),
"stored": now.isoformat(),
"expires": (now + timedelta(seconds=ttl_seconds)).isoformat(),
},
)
== 1
)
def cache_prune(max_bytes: int, moment: datetime | None = None) -> dict[str, int]:
now = (moment or datetime.now(timezone.utc)).isoformat()
expired = _execute("DELETE FROM node_cache WHERE expires_at <= :now", {"now": now})
total = next(iter(db.query("SELECT COALESCE(SUM(size), 0) AS total FROM node_cache")))["total"]
evicted = 0
while total > max_bytes:
rows = list(
db.query("SELECT key, size FROM node_cache ORDER BY stored_at, id LIMIT :n", n=BATCH_SIZE)
)
if not rows:
break
for row in rows:
if total <= max_bytes:
break
evicted += _execute("DELETE FROM node_cache WHERE key = :key", {"key": row["key"]})
total -= int(row["size"] or 0)
return {"expired": expired, "evicted": evicted, "bytes": max(0, int(total))}
def cache_clear() -> int:
return _execute("DELETE FROM node_cache", {})
def cache_stats() -> dict[str, int]:
row = next(
iter(db.query("SELECT COUNT(*) AS entries, COALESCE(SUM(size), 0) AS bytes FROM node_cache"))
)
return {"entries": int(row["entries"]), "bytes": int(row["bytes"])}