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 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)