rotorcore 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rotor/__init__.py +150 -0
- rotor/_time.py +114 -0
- rotor/backends.py +311 -0
- rotor/blob.py +452 -0
- rotor/check.py +111 -0
- rotor/client.py +475 -0
- rotor/context.py +222 -0
- rotor/dashboard.py +176 -0
- rotor/engine.py +862 -0
- rotor/errors.py +98 -0
- rotor/gateway.py +553 -0
- rotor/journal.py +291 -0
- rotor/keeper.py +148 -0
- rotor/limits.py +78 -0
- rotor/lint.py +147 -0
- rotor/live.py +525 -0
- rotor/maintenance.py +142 -0
- rotor/messages.py +582 -0
- rotor/patterns/__init__.py +25 -0
- rotor/patterns/fanout.py +68 -0
- rotor/patterns/group.py +44 -0
- rotor/patterns/hookchain.py +99 -0
- rotor/patterns/task.py +131 -0
- rotor/platforms/__init__.py +13 -0
- rotor/platforms/process.py +39 -0
- rotor/platforms/vercel.py +497 -0
- rotor/process.py +453 -0
- rotor/py.typed +0 -0
- rotor/spool.py +201 -0
- rotor/stores/__init__.py +28 -0
- rotor/stores/base.py +277 -0
- rotor/stores/sql.py +2738 -0
- rotor/testing.py +73 -0
- rotor/worker.py +342 -0
- rotorcore-0.0.1.dist-info/METADATA +65 -0
- rotorcore-0.0.1.dist-info/RECORD +38 -0
- rotorcore-0.0.1.dist-info/WHEEL +4 -0
- rotorcore-0.0.1.dist-info/licenses/LICENSE +13 -0
rotor/__init__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Durable process runtime for Python."""
|
|
2
|
+
|
|
3
|
+
from .backends import Backends
|
|
4
|
+
from .blob import Blob, BlobStore, FileBlobStore, MemoryBlobStore, S3BlobStore, VercelBlobStore
|
|
5
|
+
from .client import Client, Handle
|
|
6
|
+
from .context import (
|
|
7
|
+
PendingHook,
|
|
8
|
+
current_context,
|
|
9
|
+
idempotency_key,
|
|
10
|
+
meter,
|
|
11
|
+
record,
|
|
12
|
+
stream,
|
|
13
|
+
)
|
|
14
|
+
from .engine import TickResult
|
|
15
|
+
from .errors import (
|
|
16
|
+
CallConflict,
|
|
17
|
+
CallFailed,
|
|
18
|
+
CallTimeout,
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
ForgedFact,
|
|
21
|
+
HookError,
|
|
22
|
+
HookNotFound,
|
|
23
|
+
HookNotPending,
|
|
24
|
+
MailboxClosed,
|
|
25
|
+
MailboxOverflow,
|
|
26
|
+
NoReply,
|
|
27
|
+
NotAnActivation,
|
|
28
|
+
ProcessConflict,
|
|
29
|
+
ProcessNotFound,
|
|
30
|
+
RotorError,
|
|
31
|
+
StaleDeployment,
|
|
32
|
+
StaleLease,
|
|
33
|
+
UnknownType,
|
|
34
|
+
UnmigratablePayload,
|
|
35
|
+
)
|
|
36
|
+
from .journal import Journal
|
|
37
|
+
from .limits import Ceiling, Limits, per, per_day, per_hour, total
|
|
38
|
+
from .live import (
|
|
39
|
+
Chunk,
|
|
40
|
+
Gap,
|
|
41
|
+
LiveTransport,
|
|
42
|
+
MemoryLiveTransport,
|
|
43
|
+
PostgresLiveTransport,
|
|
44
|
+
RedisLiveTransport,
|
|
45
|
+
Settled,
|
|
46
|
+
)
|
|
47
|
+
from .maintenance import JanitorResult, ReconcileResult
|
|
48
|
+
from .messages import (
|
|
49
|
+
Cancelled,
|
|
50
|
+
ChildDone,
|
|
51
|
+
ChildFailed,
|
|
52
|
+
EmptyState,
|
|
53
|
+
ErrorInfo,
|
|
54
|
+
HandlingFailed,
|
|
55
|
+
HookExpired,
|
|
56
|
+
HookResolved,
|
|
57
|
+
LimitExceeded,
|
|
58
|
+
ProcessRef,
|
|
59
|
+
Resolution,
|
|
60
|
+
Start,
|
|
61
|
+
TerminalFailure,
|
|
62
|
+
message,
|
|
63
|
+
state,
|
|
64
|
+
)
|
|
65
|
+
from .process import DurableProcess, child_id, on, query, singleton_id
|
|
66
|
+
from .worker import Worker
|
|
67
|
+
|
|
68
|
+
__version__ = "0.0.1"
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# the process
|
|
72
|
+
"DurableProcess",
|
|
73
|
+
"ProcessRef",
|
|
74
|
+
"EmptyState",
|
|
75
|
+
"child_id",
|
|
76
|
+
"singleton_id",
|
|
77
|
+
"on",
|
|
78
|
+
"query",
|
|
79
|
+
# messages & facts
|
|
80
|
+
"message",
|
|
81
|
+
"Resolution",
|
|
82
|
+
"state",
|
|
83
|
+
"Start",
|
|
84
|
+
"ChildDone",
|
|
85
|
+
"ChildFailed",
|
|
86
|
+
"HookResolved",
|
|
87
|
+
"HookExpired",
|
|
88
|
+
"HandlingFailed",
|
|
89
|
+
"LimitExceeded",
|
|
90
|
+
"Cancelled",
|
|
91
|
+
"ErrorInfo",
|
|
92
|
+
"TerminalFailure",
|
|
93
|
+
# ambient context
|
|
94
|
+
"current_context",
|
|
95
|
+
"meter",
|
|
96
|
+
"record",
|
|
97
|
+
"stream",
|
|
98
|
+
"idempotency_key",
|
|
99
|
+
"PendingHook",
|
|
100
|
+
"Chunk",
|
|
101
|
+
"Settled",
|
|
102
|
+
"Gap",
|
|
103
|
+
"LiveTransport",
|
|
104
|
+
"MemoryLiveTransport",
|
|
105
|
+
"PostgresLiveTransport",
|
|
106
|
+
"RedisLiveTransport",
|
|
107
|
+
# limits
|
|
108
|
+
"Limits",
|
|
109
|
+
"Ceiling",
|
|
110
|
+
"per",
|
|
111
|
+
"per_day",
|
|
112
|
+
"per_hour",
|
|
113
|
+
"total",
|
|
114
|
+
# blobs & journals
|
|
115
|
+
"Blob",
|
|
116
|
+
"BlobStore",
|
|
117
|
+
"FileBlobStore",
|
|
118
|
+
"MemoryBlobStore",
|
|
119
|
+
"S3BlobStore",
|
|
120
|
+
"VercelBlobStore",
|
|
121
|
+
"Journal",
|
|
122
|
+
# the two halves
|
|
123
|
+
"Client",
|
|
124
|
+
"Handle",
|
|
125
|
+
"Worker",
|
|
126
|
+
"TickResult",
|
|
127
|
+
"ReconcileResult",
|
|
128
|
+
"JanitorResult",
|
|
129
|
+
"Backends",
|
|
130
|
+
# errors
|
|
131
|
+
"RotorError",
|
|
132
|
+
"ConfigurationError",
|
|
133
|
+
"ProcessNotFound",
|
|
134
|
+
"ProcessConflict",
|
|
135
|
+
"MailboxClosed",
|
|
136
|
+
"MailboxOverflow",
|
|
137
|
+
"NoReply",
|
|
138
|
+
"CallFailed",
|
|
139
|
+
"CallTimeout",
|
|
140
|
+
"CallConflict",
|
|
141
|
+
"ForgedFact",
|
|
142
|
+
"NotAnActivation",
|
|
143
|
+
"HookError",
|
|
144
|
+
"HookNotFound",
|
|
145
|
+
"HookNotPending",
|
|
146
|
+
"StaleLease",
|
|
147
|
+
"StaleDeployment",
|
|
148
|
+
"UnknownType",
|
|
149
|
+
"UnmigratablePayload",
|
|
150
|
+
]
|
rotor/_time.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Durations, cron, and the injectable clock.
|
|
2
|
+
|
|
3
|
+
One duration grammar everywhere: ``"30s"``, ``"5m"``, ``"2h"``, ``"1d"`` —
|
|
4
|
+
shared by timers, limits windows, retention, and lease settings. Cron is the
|
|
5
|
+
standard 5-field grammar with an optional IANA timezone, because a daemon's
|
|
6
|
+
"7am brief" lives in the user's morning, not UTC's.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time as _time
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
from .errors import ConfigurationError
|
|
17
|
+
|
|
18
|
+
_DURATION = re.compile(r"^(\d+(?:\.\d+)?)(s|m|h|d)$")
|
|
19
|
+
_UNIT = {"s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_duration(value: str | int | float) -> float:
|
|
23
|
+
"""``"90s"`` → 90.0. Bare numbers are seconds."""
|
|
24
|
+
if isinstance(value, (int, float)):
|
|
25
|
+
return float(value)
|
|
26
|
+
m = _DURATION.match(value.strip())
|
|
27
|
+
if not m:
|
|
28
|
+
raise ConfigurationError(f"bad duration {value!r} — expected e.g. '30s', '5m', '2h', '1d'")
|
|
29
|
+
return float(m.group(1)) * _UNIT[m.group(2)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Clock:
|
|
33
|
+
"""Wall clock. Tests substitute VirtualClock; everything asks the clock."""
|
|
34
|
+
|
|
35
|
+
def now(self) -> float:
|
|
36
|
+
return _time.time()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class VirtualClock(Clock):
|
|
40
|
+
"""A clock that only moves when told to. The testing harness's heart."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, start: float = 1_700_000_000.0) -> None:
|
|
43
|
+
self._now = start
|
|
44
|
+
|
|
45
|
+
def now(self) -> float:
|
|
46
|
+
return self._now
|
|
47
|
+
|
|
48
|
+
def advance(self, duration: str | int | float) -> None:
|
|
49
|
+
self._now += parse_duration(duration)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_field(field: str, lo: int, hi: int) -> set[int]:
|
|
53
|
+
values: set[int] = set()
|
|
54
|
+
for part in field.split(","):
|
|
55
|
+
step = 1
|
|
56
|
+
if "/" in part:
|
|
57
|
+
part, step_s = part.split("/", 1)
|
|
58
|
+
step = int(step_s)
|
|
59
|
+
if part == "*":
|
|
60
|
+
lo_, hi_ = lo, hi
|
|
61
|
+
elif "-" in part:
|
|
62
|
+
a, b = part.split("-", 1)
|
|
63
|
+
lo_, hi_ = int(a), int(b)
|
|
64
|
+
else:
|
|
65
|
+
lo_ = hi_ = int(part)
|
|
66
|
+
if not (lo <= lo_ <= hi_ <= hi):
|
|
67
|
+
raise ConfigurationError(f"cron field {field!r} out of range {lo}-{hi}")
|
|
68
|
+
values.update(range(lo_, hi_ + 1, step))
|
|
69
|
+
return values
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Cron:
|
|
73
|
+
"""Five fields: minute hour day-of-month month day-of-week (0=Sunday)."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, expr: str, tz: str | None = None) -> None:
|
|
76
|
+
fields = expr.split()
|
|
77
|
+
if len(fields) != 5:
|
|
78
|
+
raise ConfigurationError(f"bad cron {expr!r} — expected 5 fields")
|
|
79
|
+
self.expr = expr
|
|
80
|
+
self.tz = tz
|
|
81
|
+
self.minute = _parse_field(fields[0], 0, 59)
|
|
82
|
+
self.hour = _parse_field(fields[1], 0, 23)
|
|
83
|
+
self.dom = _parse_field(fields[2], 1, 31)
|
|
84
|
+
self.month = _parse_field(fields[3], 1, 12)
|
|
85
|
+
# accept 7 as Sunday, normalize to 0
|
|
86
|
+
self.dow = {d % 7 for d in _parse_field(fields[4], 0, 7)}
|
|
87
|
+
|
|
88
|
+
def next_after(self, ts: float) -> float:
|
|
89
|
+
"""The first matching minute strictly after ``ts``, as a unix timestamp."""
|
|
90
|
+
zone = ZoneInfo(self.tz) if self.tz else UTC
|
|
91
|
+
start = datetime.fromtimestamp(ts, zone).replace(second=0, microsecond=0)
|
|
92
|
+
start += timedelta(minutes=1)
|
|
93
|
+
day = start.date()
|
|
94
|
+
for _ in range(28 * 366): # leap-day + weekday combinations repeat within 28 years
|
|
95
|
+
if day.day in self.dom and day.month in self.month and day.weekday() in self._py_dow():
|
|
96
|
+
for hour in sorted(self.hour):
|
|
97
|
+
for minute in sorted(self.minute):
|
|
98
|
+
candidate = datetime(
|
|
99
|
+
day.year, day.month, day.day, hour, minute, tzinfo=zone
|
|
100
|
+
)
|
|
101
|
+
if candidate < start or candidate.timestamp() <= ts:
|
|
102
|
+
continue
|
|
103
|
+
# Nonexistent local times during a DST jump normalize
|
|
104
|
+
# to another wall time and therefore are not matches.
|
|
105
|
+
actual = datetime.fromtimestamp(candidate.timestamp(), zone)
|
|
106
|
+
if (actual.date(), actual.hour, actual.minute) != (day, hour, minute):
|
|
107
|
+
continue
|
|
108
|
+
return candidate.timestamp()
|
|
109
|
+
day += timedelta(days=1)
|
|
110
|
+
raise ConfigurationError(f"cron {self.expr!r} never fires")
|
|
111
|
+
|
|
112
|
+
def _py_dow(self) -> set[int]:
|
|
113
|
+
# cron: 0=Sunday..6=Saturday; python weekday(): 0=Monday..6=Sunday
|
|
114
|
+
return {(d - 1) % 7 for d in self.dow}
|
rotor/backends.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Backend resolution for the durable database and blob storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from ._time import Clock
|
|
12
|
+
from .blob import (
|
|
13
|
+
BlobStore,
|
|
14
|
+
FileBlobStore,
|
|
15
|
+
MemoryBlobStore,
|
|
16
|
+
S3BlobStore,
|
|
17
|
+
UnconfiguredBlobStore,
|
|
18
|
+
VercelBlobStore,
|
|
19
|
+
)
|
|
20
|
+
from .errors import ConfigurationError
|
|
21
|
+
from .live import (
|
|
22
|
+
LiveTransport,
|
|
23
|
+
MemoryLiveTransport,
|
|
24
|
+
PostgresLiveTransport,
|
|
25
|
+
RedisLiveTransport,
|
|
26
|
+
UnconfiguredLiveTransport,
|
|
27
|
+
)
|
|
28
|
+
from .spool import RedisSpoolStore, SpoolStore
|
|
29
|
+
from .stores.base import Store
|
|
30
|
+
from .stores.sql import PostgresStore, SqliteStore
|
|
31
|
+
|
|
32
|
+
DEV_DB = "sqlite:///.rotor/dev.db"
|
|
33
|
+
|
|
34
|
+
# Environment names set by platform database integrations, in priority order.
|
|
35
|
+
_DB_ENV_VARS = ("POSTGRES_URL", "DATABASE_URL")
|
|
36
|
+
_VERCEL_BLOB_ENV = "BLOB_READ_WRITE_TOKEN"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Backends:
|
|
40
|
+
"""The database/blob pair both halves take. A Client and a Worker built
|
|
41
|
+
from the same Backends share one store instance in-process — which is
|
|
42
|
+
also what makes ``sqlite:///:memory:`` work for tests.
|
|
43
|
+
|
|
44
|
+
With no arguments, resolution is: platform-integration environment
|
|
45
|
+
variables (``POSTGRES_URL``/``DATABASE_URL`` for the database,
|
|
46
|
+
``BLOB_READ_WRITE_TOKEN`` for blobs), then the local dev tier. Anything
|
|
47
|
+
else is configured explicitly in code."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
db: str | Store | None = None,
|
|
52
|
+
*,
|
|
53
|
+
blobs: str | BlobStore | None = None,
|
|
54
|
+
blob_threshold: int = 64 * 1024,
|
|
55
|
+
clock: Clock | None = None,
|
|
56
|
+
live: str | LiveTransport | None = None,
|
|
57
|
+
record_message_activity: bool = True,
|
|
58
|
+
) -> None:
|
|
59
|
+
if not isinstance(record_message_activity, bool):
|
|
60
|
+
raise ConfigurationError("record_message_activity must be a bool")
|
|
61
|
+
self.clock = clock or getattr(db, "clock", None) or Clock()
|
|
62
|
+
self.blob_threshold = blob_threshold
|
|
63
|
+
self._db_url = None
|
|
64
|
+
if db is not None and not isinstance(db, str):
|
|
65
|
+
self.store: Store = db # bring-your-own conformant store
|
|
66
|
+
if isinstance(db, SqliteStore):
|
|
67
|
+
self._db_url = f"sqlite:///{db.path}"
|
|
68
|
+
elif isinstance(db, PostgresStore):
|
|
69
|
+
self._db_url = db.dsn
|
|
70
|
+
else:
|
|
71
|
+
url = db or _discovered_db_url() or DEV_DB
|
|
72
|
+
self._db_url = url
|
|
73
|
+
self.store = self._store_for(url)
|
|
74
|
+
self.store.record_message_activity = record_message_activity
|
|
75
|
+
self.live = self._resolve_live(live)
|
|
76
|
+
self.spool = self._resolve_spool()
|
|
77
|
+
if isinstance(blobs, str):
|
|
78
|
+
self.blob_store: BlobStore = self._blobs_for(blobs)
|
|
79
|
+
self._blobs_desc = blobs
|
|
80
|
+
elif blobs is not None:
|
|
81
|
+
self.blob_store = blobs
|
|
82
|
+
self._blobs_desc = type(blobs).__name__
|
|
83
|
+
else:
|
|
84
|
+
self.blob_store = self._default_blobs()
|
|
85
|
+
self._blobs_desc = self._default_blobs_desc()
|
|
86
|
+
self._ready = False
|
|
87
|
+
self._setup_lock = asyncio.Lock()
|
|
88
|
+
|
|
89
|
+
def _store_for(self, url: str) -> Store:
|
|
90
|
+
if url.startswith("sqlite:///"):
|
|
91
|
+
path = url[len("sqlite:///") :]
|
|
92
|
+
return SqliteStore(path, clock=self.clock)
|
|
93
|
+
if url.startswith(("postgres://", "postgresql://")):
|
|
94
|
+
return PostgresStore(url, clock=self.clock)
|
|
95
|
+
raise ConfigurationError(f"unsupported db URL {url!r} — expected sqlite:/// or postgres://")
|
|
96
|
+
|
|
97
|
+
def _blobs_for(self, url: str) -> BlobStore:
|
|
98
|
+
if url.startswith("file://"):
|
|
99
|
+
return FileBlobStore(url[len("file://") :])
|
|
100
|
+
if url.startswith("memory://"):
|
|
101
|
+
return MemoryBlobStore()
|
|
102
|
+
if url.startswith("s3://"):
|
|
103
|
+
parsed = urlparse(url)
|
|
104
|
+
return S3BlobStore(parsed.netloc, prefix=parsed.path.strip("/"))
|
|
105
|
+
raise ConfigurationError(
|
|
106
|
+
f"unsupported blobs URL {url!r} — expected file://, memory://, or s3://"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _resolve_live(self, live: str | LiveTransport | None) -> LiveTransport:
|
|
110
|
+
if live is not None and not isinstance(live, str):
|
|
111
|
+
return live
|
|
112
|
+
url = live or os.getenv("KV_URL") or os.getenv("REDIS_URL")
|
|
113
|
+
if url:
|
|
114
|
+
if url.startswith(("redis://", "rediss://")):
|
|
115
|
+
return RedisLiveTransport(url)
|
|
116
|
+
if url.startswith(("postgres://", "postgresql://")):
|
|
117
|
+
return PostgresLiveTransport(url)
|
|
118
|
+
raise ConfigurationError(
|
|
119
|
+
f"unsupported live URL {url!r} — expected redis:// or postgres://"
|
|
120
|
+
)
|
|
121
|
+
direct = os.getenv("POSTGRES_URL_NON_POOLING")
|
|
122
|
+
if direct:
|
|
123
|
+
return PostgresLiveTransport(direct)
|
|
124
|
+
if isinstance(self.store, SqliteStore):
|
|
125
|
+
return MemoryLiveTransport()
|
|
126
|
+
if isinstance(self.store, PostgresStore):
|
|
127
|
+
return PostgresLiveTransport(self.store.dsn)
|
|
128
|
+
return UnconfiguredLiveTransport(
|
|
129
|
+
"live streaming is not configured: pass Backends(live='redis://...') "
|
|
130
|
+
"or a direct PostgreSQL URL"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _resolve_spool(self) -> SpoolStore:
|
|
134
|
+
"""The spool follows the live tier: a deployment that bought Redis
|
|
135
|
+
to take per-chunk traffic off Postgres gets the spool's per-chunk
|
|
136
|
+
writes there too; everyone else rides the SQL store, whose commit
|
|
137
|
+
clears the buffer transactionally for free.
|
|
138
|
+
|
|
139
|
+
Correctness never depends on this choice. Validity is anchored
|
|
140
|
+
solely in the main store's facts (lease, activation, mailbox row);
|
|
141
|
+
the spool and the live channel are both payload planes, and the
|
|
142
|
+
reader degrades any divergence between them to a Gap."""
|
|
143
|
+
if isinstance(self.live, RedisLiveTransport):
|
|
144
|
+
return RedisSpoolStore(self.live.url)
|
|
145
|
+
# the payload plane is part of the Store protocol: a bring-your-own
|
|
146
|
+
# store that omits it fails loudly at first use, the same way it
|
|
147
|
+
# would for any other missing protocol method
|
|
148
|
+
return self.store
|
|
149
|
+
|
|
150
|
+
def _default_blobs(self) -> BlobStore:
|
|
151
|
+
token = os.getenv(_VERCEL_BLOB_ENV)
|
|
152
|
+
if token:
|
|
153
|
+
return VercelBlobStore(token)
|
|
154
|
+
if deployed_on_vercel():
|
|
155
|
+
# Boot without blob storage; large values fail loudly at first use.
|
|
156
|
+
return UnconfiguredBlobStore(
|
|
157
|
+
"blob storage is not configured: add a Vercel Blob store to this project "
|
|
158
|
+
f"(sets {_VERCEL_BLOB_ENV}) or pass Backends(blobs=...) explicitly"
|
|
159
|
+
)
|
|
160
|
+
if isinstance(self.store, SqliteStore):
|
|
161
|
+
if self.store.path == ":memory:":
|
|
162
|
+
return MemoryBlobStore()
|
|
163
|
+
return FileBlobStore(Path(self.store.path).parent / "blobs")
|
|
164
|
+
return FileBlobStore(".rotor/blobs")
|
|
165
|
+
|
|
166
|
+
def _default_blobs_desc(self) -> str:
|
|
167
|
+
if isinstance(self.blob_store, MemoryBlobStore):
|
|
168
|
+
return "memory (dev)"
|
|
169
|
+
if isinstance(self.blob_store, FileBlobStore):
|
|
170
|
+
return f"file://{self.blob_store.root}"
|
|
171
|
+
if isinstance(self.blob_store, VercelBlobStore):
|
|
172
|
+
return f"vercel blob ({_VERCEL_BLOB_ENV})"
|
|
173
|
+
if isinstance(self.blob_store, UnconfiguredBlobStore):
|
|
174
|
+
return "unconfigured (fails on first use)"
|
|
175
|
+
return type(self.blob_store).__name__
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def in_memory(self) -> bool:
|
|
179
|
+
return self._db_url == "sqlite:///:memory:"
|
|
180
|
+
|
|
181
|
+
def thread_store_factory(self) -> Callable[[], Store] | None:
|
|
182
|
+
"""A fresh store for the lease keeper's thread — its own connection,
|
|
183
|
+
same database, same clock. None when the database can't be duplicated:
|
|
184
|
+
a bring-your-own store object, or sqlite ``:memory:`` (a second
|
|
185
|
+
connection would see a different empty database)."""
|
|
186
|
+
if self._db_url is None or self._db_url == "sqlite:///:memory:":
|
|
187
|
+
return None
|
|
188
|
+
url: str = self._db_url
|
|
189
|
+
clock = getattr(self.store, "clock", self.clock)
|
|
190
|
+
record_message_activity = self.store.record_message_activity
|
|
191
|
+
|
|
192
|
+
def make() -> Store:
|
|
193
|
+
if url.startswith("sqlite:///"):
|
|
194
|
+
store: Store = SqliteStore(url[len("sqlite:///") :], clock=clock)
|
|
195
|
+
else:
|
|
196
|
+
store = PostgresStore(
|
|
197
|
+
url,
|
|
198
|
+
clock=clock,
|
|
199
|
+
pool_min=1,
|
|
200
|
+
pool_max=2,
|
|
201
|
+
deployment_id=getattr(self.store, "deployment_id", None),
|
|
202
|
+
)
|
|
203
|
+
store.record_message_activity = record_message_activity
|
|
204
|
+
return store
|
|
205
|
+
|
|
206
|
+
return make
|
|
207
|
+
|
|
208
|
+
async def setup(self) -> None:
|
|
209
|
+
self.validate_vercel()
|
|
210
|
+
if self._ready:
|
|
211
|
+
return
|
|
212
|
+
async with self._setup_lock:
|
|
213
|
+
if self._ready:
|
|
214
|
+
return
|
|
215
|
+
await self.store.setup()
|
|
216
|
+
try:
|
|
217
|
+
namespace = self.runtime_namespace()
|
|
218
|
+
if namespace is not None:
|
|
219
|
+
await self.store.bind_runtime(namespace)
|
|
220
|
+
await self.live.setup()
|
|
221
|
+
if self.spool is not self.store:
|
|
222
|
+
await self.spool.setup()
|
|
223
|
+
except BaseException:
|
|
224
|
+
await self.store.close()
|
|
225
|
+
raise
|
|
226
|
+
self._ready = True
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def runtime_namespace() -> str | None:
|
|
230
|
+
"""``project/environment`` this deployment binds its database to, or
|
|
231
|
+
None off deployed Vercel. Setup rejects a database bound elsewhere;
|
|
232
|
+
only explicit activation may rebind one (see the store's deployment fence)."""
|
|
233
|
+
if not deployed_on_vercel():
|
|
234
|
+
return None
|
|
235
|
+
project = os.getenv("VERCEL_PROJECT_ID")
|
|
236
|
+
if not project or not os.getenv("VERCEL_DEPLOYMENT_ID"):
|
|
237
|
+
raise ConfigurationError("Rotor needs VERCEL_PROJECT_ID and VERCEL_DEPLOYMENT_ID")
|
|
238
|
+
environment = os.getenv("VERCEL_TARGET_ENV") or os.environ["VERCEL_ENV"]
|
|
239
|
+
return f"{project}/{environment}"
|
|
240
|
+
|
|
241
|
+
def validate_vercel(self) -> None:
|
|
242
|
+
"""Reject process-local backends before either half uses them.
|
|
243
|
+
|
|
244
|
+
Enforced only on deployed Vercel (production or preview): a function's
|
|
245
|
+
filesystem is ephemeral, so local backends there mean silent state
|
|
246
|
+
loss. ``vercel dev`` sets ``VERCEL_ENV=development`` and local
|
|
247
|
+
backends work with no configuration."""
|
|
248
|
+
if not deployed_on_vercel():
|
|
249
|
+
return
|
|
250
|
+
if isinstance(self.store, SqliteStore):
|
|
251
|
+
raise ConfigurationError(
|
|
252
|
+
"Rotor on Vercel requires a shared durable database: add a Postgres "
|
|
253
|
+
"integration (sets POSTGRES_URL) or pass Backends(db=...) explicitly"
|
|
254
|
+
)
|
|
255
|
+
if isinstance(self.blob_store, (FileBlobStore, MemoryBlobStore)):
|
|
256
|
+
raise ConfigurationError(
|
|
257
|
+
"Rotor on Vercel requires durable shared blob storage: add a Vercel Blob "
|
|
258
|
+
f"store (sets {_VERCEL_BLOB_ENV}) or pass Backends(blobs=...) explicitly"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
async def close(self) -> None:
|
|
262
|
+
if self._ready:
|
|
263
|
+
if self.spool is not self.store:
|
|
264
|
+
await self.spool.close()
|
|
265
|
+
await self.live.close()
|
|
266
|
+
await self.store.close()
|
|
267
|
+
self._ready = False
|
|
268
|
+
|
|
269
|
+
def banner(self, role: str, extra: dict[str, str] | None = None) -> str:
|
|
270
|
+
if isinstance(self.store, SqliteStore):
|
|
271
|
+
tier = "dev tier" if self.store.path == ":memory:" else "durable ✓ (fsync)"
|
|
272
|
+
db = f"sqlite:///{self.store.path} {tier}"
|
|
273
|
+
elif isinstance(self.store, PostgresStore):
|
|
274
|
+
db = f"{_redact(self.store.dsn)} durable ✓ (WAL)"
|
|
275
|
+
else:
|
|
276
|
+
db = type(self.store).__name__
|
|
277
|
+
lines = [
|
|
278
|
+
f"rotor · {role}",
|
|
279
|
+
f" db {db}",
|
|
280
|
+
f" blobs {self._blobs_desc}",
|
|
281
|
+
]
|
|
282
|
+
for key, value in (extra or {}).items():
|
|
283
|
+
lines.append(f" {key:<7} {value}")
|
|
284
|
+
return "\n".join(lines)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _discovered_db_url() -> str | None:
|
|
288
|
+
for name in _DB_ENV_VARS:
|
|
289
|
+
url = os.getenv(name)
|
|
290
|
+
if url:
|
|
291
|
+
return url
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _redact(dsn: str) -> str:
|
|
296
|
+
if "@" in dsn and "://" in dsn:
|
|
297
|
+
scheme, rest = dsn.split("://", 1)
|
|
298
|
+
_creds, _, host = rest.rpartition("@")
|
|
299
|
+
return f"{scheme}://…@{host}"
|
|
300
|
+
return dsn
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def deployed_on_vercel() -> bool:
|
|
304
|
+
"""Deployed means production or preview; ``vercel dev`` is development."""
|
|
305
|
+
return os.getenv("VERCEL_ENV") in ("production", "preview")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def resolve(backends: Backends | str | None) -> Backends:
|
|
309
|
+
if isinstance(backends, Backends):
|
|
310
|
+
return backends
|
|
311
|
+
return Backends(backends)
|