pinboard-sdk 0.18.0__tar.gz
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.
- pinboard_sdk-0.18.0/.gitignore +9 -0
- pinboard_sdk-0.18.0/PKG-INFO +57 -0
- pinboard_sdk-0.18.0/README.md +40 -0
- pinboard_sdk-0.18.0/examples/__init__.py +0 -0
- pinboard_sdk-0.18.0/examples/durable_tray_app.py +69 -0
- pinboard_sdk-0.18.0/pyproject.toml +59 -0
- pinboard_sdk-0.18.0/src/pinboard/__init__.py +74 -0
- pinboard_sdk-0.18.0/src/pinboard/api.py +178 -0
- pinboard_sdk-0.18.0/src/pinboard/domain.py +73 -0
- pinboard_sdk-0.18.0/src/pinboard/durability/__init__.py +11 -0
- pinboard_sdk-0.18.0/src/pinboard/durability/frontier.py +194 -0
- pinboard_sdk-0.18.0/src/pinboard/durability/postgres.py +309 -0
- pinboard_sdk-0.18.0/src/pinboard/durability/store.py +159 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/__init__.py +13 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/_fencing.py +56 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/_records.py +168 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/_winddown.py +292 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/directory.py +581 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/dispatch.py +323 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/host.py +264 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/hostable.py +86 -0
- pinboard_sdk-0.18.0/src/pinboard/hosting/routing.py +61 -0
- pinboard_sdk-0.18.0/src/pinboard/lifespan.py +19 -0
- pinboard_sdk-0.18.0/src/pinboard/metrics.py +71 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/__init__.py +223 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/cluster.py +343 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/contract.py +418 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/coprocess.py +124 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/e2e.py +168 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/fleet.py +133 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/harness.py +346 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/node.py +99 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/store.py +192 -0
- pinboard_sdk-0.18.0/src/pinboard/sim/workload.py +209 -0
- pinboard_sdk-0.18.0/src/pinboard/testing.py +243 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/__init__.py +50 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/_attempts.py +62 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/_declare.py +134 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/_dispatch.py +115 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/_lifecycle.py +283 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/_machine.py +473 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/_primitives.py +302 -0
- pinboard_sdk-0.18.0/src/pinboard/trays/_storage.py +153 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/__init__.py +22 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_acks.py +91 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_attestation.py +149 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_backoff.py +48 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_cluster.py +142 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_drain_echo.py +56 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_headroom.py +35 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_registration.py +452 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/_worker.py +481 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/lease.py +237 -0
- pinboard_sdk-0.18.0/src/pinboard/worker/wire.py +59 -0
- pinboard_sdk-0.18.0/tests/conftest.py +78 -0
- pinboard_sdk-0.18.0/tests/test_activation_limit.py +200 -0
- pinboard_sdk-0.18.0/tests/test_authorize_gate.py +537 -0
- pinboard_sdk-0.18.0/tests/test_cluster.py +409 -0
- pinboard_sdk-0.18.0/tests/test_control_messages.py +271 -0
- pinboard_sdk-0.18.0/tests/test_control_plane.py +533 -0
- pinboard_sdk-0.18.0/tests/test_crash_sweep.py +466 -0
- pinboard_sdk-0.18.0/tests/test_create_task.py +60 -0
- pinboard_sdk-0.18.0/tests/test_detached_timeout.py +203 -0
- pinboard_sdk-0.18.0/tests/test_dispatch.py +460 -0
- pinboard_sdk-0.18.0/tests/test_dispatch_state.py +141 -0
- pinboard_sdk-0.18.0/tests/test_domain.py +85 -0
- pinboard_sdk-0.18.0/tests/test_drain.py +207 -0
- pinboard_sdk-0.18.0/tests/test_durability.py +537 -0
- pinboard_sdk-0.18.0/tests/test_durability_postgres.py +647 -0
- pinboard_sdk-0.18.0/tests/test_e2e_sim.py +49 -0
- pinboard_sdk-0.18.0/tests/test_e2e_tray_durability.py +234 -0
- pinboard_sdk-0.18.0/tests/test_eviction.py +360 -0
- pinboard_sdk-0.18.0/tests/test_hostable.py +50 -0
- pinboard_sdk-0.18.0/tests/test_id_validation.py +49 -0
- pinboard_sdk-0.18.0/tests/test_lease.py +160 -0
- pinboard_sdk-0.18.0/tests/test_lifecycle.py +319 -0
- pinboard_sdk-0.18.0/tests/test_pinboard_integration.py +221 -0
- pinboard_sdk-0.18.0/tests/test_placement_epoch.py +126 -0
- pinboard_sdk-0.18.0/tests/test_placement_health.py +72 -0
- pinboard_sdk-0.18.0/tests/test_registration.py +1171 -0
- pinboard_sdk-0.18.0/tests/test_sabotage.py +197 -0
- pinboard_sdk-0.18.0/tests/test_sim.py +244 -0
- pinboard_sdk-0.18.0/tests/test_simstore.py +202 -0
- pinboard_sdk-0.18.0/tests/test_state_store.py +185 -0
- pinboard_sdk-0.18.0/tests/test_streaming.py +140 -0
- pinboard_sdk-0.18.0/tests/test_take.py +153 -0
- pinboard_sdk-0.18.0/tests/test_testing.py +93 -0
- pinboard_sdk-0.18.0/tests/test_trays.py +1522 -0
- pinboard_sdk-0.18.0/tests/test_traystore.py +600 -0
- pinboard_sdk-0.18.0/tests/test_websocket.py +359 -0
- pinboard_sdk-0.18.0/tests/test_wind_down.py +374 -0
- pinboard_sdk-0.18.0/tests/test_worker.py +399 -0
- pinboard_sdk-0.18.0/tests/test_worker_hosts.py +129 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pinboard-sdk
|
|
3
|
+
Version: 0.18.0
|
|
4
|
+
Summary: One live instance of your class per id, with an HTTP surface
|
|
5
|
+
Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: fastapi>=0.115
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: starlette>=0.37
|
|
11
|
+
Requires-Dist: statepatch<0.4,>=0.3.0
|
|
12
|
+
Provides-Extra: postgres
|
|
13
|
+
Requires-Dist: psycopg>=3.1; extra == 'postgres'
|
|
14
|
+
Provides-Extra: psutil
|
|
15
|
+
Requires-Dist: psutil>=5.9; extra == 'psutil'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# pinboard-sdk
|
|
19
|
+
|
|
20
|
+
Durable objects for Python: one live instance of your class per id across a fleet, activated on the first request, with its own HTTP surface. Imports as `pinboard`. Part of [harness-sdk](https://github.com/assistant-ui/harness-sdk), with `statewire` and `harness-sdk`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
uv add pinboard-sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Extras: `uv add "pinboard-sdk[postgres]"`, `uv add "pinboard-sdk[psutil]"`.
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from fastapi import FastAPI
|
|
34
|
+
from pinboard import PinboardAPI, Worker, route
|
|
35
|
+
from pinboard.durability import MemoryStateStore
|
|
36
|
+
|
|
37
|
+
class Counter(PinboardAPI):
|
|
38
|
+
async def lifespan(self):
|
|
39
|
+
self.count = 0
|
|
40
|
+
yield
|
|
41
|
+
|
|
42
|
+
@route.post("/increment")
|
|
43
|
+
async def increment(self):
|
|
44
|
+
self.count += 1
|
|
45
|
+
return {"id": self.id, "count": self.count}
|
|
46
|
+
|
|
47
|
+
worker = Worker(hosts={"/counters": Counter}, state_store=MemoryStateStore())
|
|
48
|
+
app = FastAPI()
|
|
49
|
+
app.include_router(worker)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Docs
|
|
53
|
+
|
|
54
|
+
- Get started: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/get-started/durable-host/page.mdx
|
|
55
|
+
- Guide: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/guides/python/authoring/page.mdx
|
|
56
|
+
- Reference: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/reference/pinboard-python/page.mdx
|
|
57
|
+
- Spec: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/spec/gateway-registry/page.mdx
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# pinboard-sdk
|
|
2
|
+
|
|
3
|
+
Durable objects for Python: one live instance of your class per id across a fleet, activated on the first request, with its own HTTP surface. Imports as `pinboard`. Part of [harness-sdk](https://github.com/assistant-ui/harness-sdk), with `statewire` and `harness-sdk`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv add pinboard-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Extras: `uv add "pinboard-sdk[postgres]"`, `uv add "pinboard-sdk[psutil]"`.
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from pinboard import PinboardAPI, Worker, route
|
|
18
|
+
from pinboard.durability import MemoryStateStore
|
|
19
|
+
|
|
20
|
+
class Counter(PinboardAPI):
|
|
21
|
+
async def lifespan(self):
|
|
22
|
+
self.count = 0
|
|
23
|
+
yield
|
|
24
|
+
|
|
25
|
+
@route.post("/increment")
|
|
26
|
+
async def increment(self):
|
|
27
|
+
self.count += 1
|
|
28
|
+
return {"id": self.id, "count": self.count}
|
|
29
|
+
|
|
30
|
+
worker = Worker(hosts={"/counters": Counter}, state_store=MemoryStateStore())
|
|
31
|
+
app = FastAPI()
|
|
32
|
+
app.include_router(worker)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Docs
|
|
36
|
+
|
|
37
|
+
- Get started: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/get-started/durable-host/page.mdx
|
|
38
|
+
- Guide: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/guides/python/authoring/page.mdx
|
|
39
|
+
- Reference: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/reference/pinboard-python/page.mdx
|
|
40
|
+
- Spec: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/src/app/pinboard/docs/spec/gateway-registry/page.mdx
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Durable tray worker: the minimal gateway + tray durability config.
|
|
2
|
+
|
|
3
|
+
Each ``/lanes/{id}`` instance owns one tray. ``POST /enqueue`` returns once the
|
|
4
|
+
item is durably admitted; the handler records the item in ``executed`` when it runs.
|
|
5
|
+
The gateway's durability sweep (``PINBOARD_DURABILITY_POSTGRES_URL`` on the
|
|
6
|
+
pinboard side, pointing at the same database) resurrects lanes with pending
|
|
7
|
+
work after any combination of worker crashes and pinboard/redis wipes.
|
|
8
|
+
|
|
9
|
+
Env: POSTGRES_DSN, PINBOARD_URL, PINBOARD_TOKEN, ADVERTISE_URL,
|
|
10
|
+
HEARTBEAT_INTERVAL (default 3).
|
|
11
|
+
Run: uv run uvicorn examples.durable_tray_app:app --port 8000
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
|
|
20
|
+
from pinboard import PinboardAPI, Worker, Tray, inbox, route, tray
|
|
21
|
+
from pinboard.durability.postgres import PostgresStateStore
|
|
22
|
+
from pinboard.trays import persistent_queue
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WorkTray(Tray):
|
|
26
|
+
queue = persistent_queue()
|
|
27
|
+
|
|
28
|
+
def items(self):
|
|
29
|
+
while self.queue.value:
|
|
30
|
+
yield self.queue.peek()
|
|
31
|
+
self.queue.popleft()
|
|
32
|
+
|
|
33
|
+
@inbox
|
|
34
|
+
def add(self, item: dict) -> None:
|
|
35
|
+
self.kick()
|
|
36
|
+
self.queue.append(item)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Lane(PinboardAPI):
|
|
40
|
+
@tray(WorkTray)
|
|
41
|
+
async def work(self, item: dict, ctx) -> None:
|
|
42
|
+
if ctx.attempts == 1 and item.get("hold"):
|
|
43
|
+
await asyncio.Event().wait() # chaos: first delivery completes only by kill
|
|
44
|
+
executed = ctx.tray.storage.get("executed", {})
|
|
45
|
+
ctx.tray.storage.set("executed", {**executed, item["id"]: ctx.attempts})
|
|
46
|
+
|
|
47
|
+
@route.post("/enqueue")
|
|
48
|
+
async def enqueue(self, item: dict) -> dict:
|
|
49
|
+
lane = await self.work.get()
|
|
50
|
+
await lane.add(item) # the durable-admission receipt
|
|
51
|
+
return {"admitted": item["id"]}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
store = PostgresStateStore(dsn=os.environ["POSTGRES_DSN"])
|
|
55
|
+
worker = Worker(
|
|
56
|
+
hosts={"/lanes": Lane},
|
|
57
|
+
heartbeat_interval=float(os.environ.get("HEARTBEAT_INTERVAL", "3")),
|
|
58
|
+
state_store=store,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextlib.asynccontextmanager
|
|
63
|
+
async def lifespan(app: FastAPI):
|
|
64
|
+
await store.setup()
|
|
65
|
+
yield
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
app = FastAPI(lifespan=lifespan)
|
|
69
|
+
app.include_router(worker)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pinboard-sdk"
|
|
7
|
+
version = "0.18.0"
|
|
8
|
+
description = "One live instance of your class per id, with an HTTP surface"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = ["fastapi>=0.115", "starlette>=0.37", "httpx>=0.27", "statepatch>=0.3.0,<0.4"]
|
|
13
|
+
|
|
14
|
+
[tool.uv.sources]
|
|
15
|
+
statepatch = { workspace = true }
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
psutil = ["psutil>=5.9"]
|
|
19
|
+
postgres = ["psycopg>=3.1"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://github.com/assistant-ui/harness-sdk"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/pinboard"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
extend = "../../pyproject.toml"
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
31
|
+
"time.time".msg = "read the clock through a Domain (pinboard.domain)"
|
|
32
|
+
"time.monotonic".msg = "read the clock through a Domain (pinboard.domain)"
|
|
33
|
+
"time.perf_counter".msg = "read the clock through a Domain (pinboard.domain)"
|
|
34
|
+
"time.time_ns".msg = "read the clock through a Domain (pinboard.domain)"
|
|
35
|
+
"time.monotonic_ns".msg = "read the clock through a Domain (pinboard.domain)"
|
|
36
|
+
"time.perf_counter_ns".msg = "read the clock through a Domain (pinboard.domain)"
|
|
37
|
+
"time.sleep".msg = "sleep through a Domain (pinboard.domain)"
|
|
38
|
+
"asyncio.sleep".msg = "sleep through a Domain (pinboard.domain)"
|
|
39
|
+
"asyncio.wait_for".msg = "wait through a Domain (pinboard.domain)"
|
|
40
|
+
"asyncio.timeout".msg = "wait through a Domain (pinboard.domain)"
|
|
41
|
+
"asyncio.create_task".msg = "spawn through a Domain (pinboard.domain)"
|
|
42
|
+
"asyncio.ensure_future".msg = "spawn through a Domain (pinboard.domain)"
|
|
43
|
+
"asyncio.gather".msg = "gather turns coroutines into ambient tasks; spawn through a Domain (pinboard.domain) and gather the tasks"
|
|
44
|
+
"datetime.datetime.now".msg = "read the clock through a Domain (pinboard.domain)"
|
|
45
|
+
"datetime.datetime.utcnow".msg = "read the clock through a Domain (pinboard.domain)"
|
|
46
|
+
"datetime.date.today".msg = "read the clock through a Domain (pinboard.domain)"
|
|
47
|
+
"random".msg = "draw from a Domain rng stream (pinboard.domain)"
|
|
48
|
+
"secrets".msg = "mint tokens through a Domain (pinboard.domain)"
|
|
49
|
+
"uuid.uuid4".msg = "mint tokens through a Domain (pinboard.domain)"
|
|
50
|
+
"uuid.uuid1".msg = "mint tokens through a Domain (pinboard.domain)"
|
|
51
|
+
"os.urandom".msg = "mint tokens through a Domain (pinboard.domain)"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint.per-file-ignores]
|
|
54
|
+
"src/pinboard/domain.py" = ["TID251"]
|
|
55
|
+
"src/pinboard/sim/__init__.py" = ["TID251"]
|
|
56
|
+
"src/pinboard/sim/harness.py" = ["TID251"]
|
|
57
|
+
"src/pinboard/testing.py" = ["TID251"]
|
|
58
|
+
"tests/**" = ["TID251"]
|
|
59
|
+
"examples/**" = ["TID251"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from .api import AuthRejected, PinboardAPI, PinboardContext, auth_result
|
|
2
|
+
from .domain import Domain, RealDomain
|
|
3
|
+
from .durability import (
|
|
4
|
+
FenceLost,
|
|
5
|
+
Frontier,
|
|
6
|
+
InstanceDurability,
|
|
7
|
+
Receipt,
|
|
8
|
+
StateStore,
|
|
9
|
+
)
|
|
10
|
+
from statepatch import Observable, observable
|
|
11
|
+
|
|
12
|
+
from .hosting.host import PinboardHost
|
|
13
|
+
from .trays import (
|
|
14
|
+
Tray,
|
|
15
|
+
TrayCtx,
|
|
16
|
+
TrayDescriptor,
|
|
17
|
+
TrayMapNamespace,
|
|
18
|
+
TrayNamespace,
|
|
19
|
+
TrayStorage,
|
|
20
|
+
inbox,
|
|
21
|
+
persistent,
|
|
22
|
+
persistent_outbox,
|
|
23
|
+
persistent_queue,
|
|
24
|
+
tray,
|
|
25
|
+
)
|
|
26
|
+
from .hosting.hostable import Hostable, HostableContext
|
|
27
|
+
from .hosting.routing import route
|
|
28
|
+
from .worker import (
|
|
29
|
+
Attestation,
|
|
30
|
+
EvictionRequested,
|
|
31
|
+
Host,
|
|
32
|
+
InstanceLease,
|
|
33
|
+
Lease,
|
|
34
|
+
Worker,
|
|
35
|
+
attestation,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Attestation",
|
|
40
|
+
"attestation",
|
|
41
|
+
"AuthRejected",
|
|
42
|
+
"Domain",
|
|
43
|
+
"RealDomain",
|
|
44
|
+
"auth_result",
|
|
45
|
+
"PinboardAPI",
|
|
46
|
+
"PinboardContext",
|
|
47
|
+
"Host",
|
|
48
|
+
"PinboardHost",
|
|
49
|
+
"Worker",
|
|
50
|
+
"Hostable",
|
|
51
|
+
"HostableContext",
|
|
52
|
+
"InstanceLease",
|
|
53
|
+
"EvictionRequested",
|
|
54
|
+
"Lease",
|
|
55
|
+
"route",
|
|
56
|
+
"StateStore",
|
|
57
|
+
"FenceLost",
|
|
58
|
+
"InstanceDurability",
|
|
59
|
+
"Frontier",
|
|
60
|
+
"Receipt",
|
|
61
|
+
"Observable",
|
|
62
|
+
"observable",
|
|
63
|
+
"Tray",
|
|
64
|
+
"TrayCtx",
|
|
65
|
+
"TrayDescriptor",
|
|
66
|
+
"TrayMapNamespace",
|
|
67
|
+
"TrayNamespace",
|
|
68
|
+
"tray",
|
|
69
|
+
"inbox",
|
|
70
|
+
"TrayStorage",
|
|
71
|
+
"persistent",
|
|
72
|
+
"persistent_queue",
|
|
73
|
+
"persistent_outbox",
|
|
74
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Coroutine, Literal
|
|
3
|
+
|
|
4
|
+
from starlette.requests import HTTPConnection
|
|
5
|
+
|
|
6
|
+
from .domain import Domain
|
|
7
|
+
from .durability import Frontier, InstanceDurability
|
|
8
|
+
from .worker.lease import EvictionRequested, InstanceLease
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .hostable import Hostable, HostableContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
AUTH_RESULT_SCOPE_KEY = "pinned.auth_result"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthRejected(Exception):
|
|
20
|
+
"""Deliberate route denial with an exact optional transport payload."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
*,
|
|
26
|
+
status_code: Literal[401, 403] = 401,
|
|
27
|
+
payload: Any = None,
|
|
28
|
+
code: str | None = None,
|
|
29
|
+
):
|
|
30
|
+
if type(status_code) is not int or status_code not in (401, 403):
|
|
31
|
+
raise ValueError("AuthRejected status_code must be 401 or 403")
|
|
32
|
+
if not isinstance(message, str):
|
|
33
|
+
raise TypeError("AuthRejected message must be a string")
|
|
34
|
+
if code is not None and not isinstance(code, str):
|
|
35
|
+
raise TypeError("AuthRejected code must be a string")
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.message = message
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
self.payload = payload
|
|
40
|
+
self.code = code
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def auth_result(conn: HTTPConnection) -> Any:
|
|
44
|
+
"""The current route resolver's return value, for ``Depends(auth_result)``."""
|
|
45
|
+
if AUTH_RESULT_SCOPE_KEY not in conn.scope:
|
|
46
|
+
raise RuntimeError("auth_result resolves only inside a pinned route")
|
|
47
|
+
return conn.scope[AUTH_RESULT_SCOPE_KEY]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PinboardContext:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
id: str,
|
|
54
|
+
*,
|
|
55
|
+
lease: InstanceLease,
|
|
56
|
+
abort: Callable[[], None],
|
|
57
|
+
create_task: Callable[[Coroutine[Any, Any, Any]], "asyncio.Task[Any]"],
|
|
58
|
+
durability: InstanceDurability | None = None,
|
|
59
|
+
cluster: "httpx.AsyncClient | None" = None,
|
|
60
|
+
domain: Domain,
|
|
61
|
+
create_durable_task: Callable[[Coroutine[Any, Any, Any]], "asyncio.Task[Any]"]
|
|
62
|
+
| None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.id = id
|
|
65
|
+
self.lease = lease
|
|
66
|
+
self.durability = durability
|
|
67
|
+
self.cluster = cluster
|
|
68
|
+
self.domain = domain
|
|
69
|
+
self._abort = abort
|
|
70
|
+
self._create_task = create_task
|
|
71
|
+
if durability is not None:
|
|
72
|
+
durability.frontier = Frontier(
|
|
73
|
+
durability, self.abort, create_durable_task or self.create_task
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def abort(self) -> None:
|
|
77
|
+
self._abort()
|
|
78
|
+
|
|
79
|
+
def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
|
|
80
|
+
return self._create_task(coro)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PinboardAPI:
|
|
84
|
+
# The instance base depends only on the context interface, not the concrete
|
|
85
|
+
# PinboardContext — a PinboardAPI would host unchanged under any HostableContext.
|
|
86
|
+
def __init__(self, ctx: "HostableContext") -> None:
|
|
87
|
+
self._ctx = ctx
|
|
88
|
+
|
|
89
|
+
async def authorize(self, conn: HTTPConnection) -> Any:
|
|
90
|
+
"""Auth seam gating every ``@route`` handler: raise ``AuthRejected``
|
|
91
|
+
to reject. The return value is the caller's identity — any host-chosen
|
|
92
|
+
object, ``None`` for anonymous — consumed via ``Depends(auth_result)``.
|
|
93
|
+
|
|
94
|
+
Runs before ``lifespan`` on a cold instance, so a denied request never
|
|
95
|
+
activates: ``self.id`` is available, state built in ``lifespan`` is
|
|
96
|
+
not — cold and warm calls share that one contract. Runs per request; writes to ``self`` are latest-wins."""
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
async def _route_failed(self, conn: HTTPConnection, message: str) -> None:
|
|
100
|
+
"""Protocol notification before a failed WebSocket route closes."""
|
|
101
|
+
|
|
102
|
+
async def _route_refused(
|
|
103
|
+
self,
|
|
104
|
+
conn: HTTPConnection,
|
|
105
|
+
message: str,
|
|
106
|
+
*,
|
|
107
|
+
status: int,
|
|
108
|
+
retry_after: float,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Protocol notification before a refused (retryable) WebSocket route closes."""
|
|
111
|
+
|
|
112
|
+
def stats(self) -> "dict[str, Any] | None":
|
|
113
|
+
"""Optional per-instance wire stats merged into ``/_pinned/stats`` rows."""
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def id(self) -> str:
|
|
118
|
+
return self._ctx.id
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def lease(self) -> InstanceLease:
|
|
122
|
+
return self._ctx.lease
|
|
123
|
+
|
|
124
|
+
def abort(self) -> None:
|
|
125
|
+
self._ctx.abort()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def domain(self) -> Domain:
|
|
129
|
+
"""Ambient primitives — now/monotonic/sleep/wait/spawn/rng/token_hex — simulable under ``SimDomain``."""
|
|
130
|
+
return self._ctx.domain
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def cluster(self) -> "httpx.AsyncClient":
|
|
134
|
+
"""HTTP client addressing other instances by data-plane path (``/<ns>/<id>/...``), with this instance attested as the caller."""
|
|
135
|
+
cluster = self._ctx.cluster
|
|
136
|
+
if cluster is None:
|
|
137
|
+
raise RuntimeError("self.cluster requires a PinboardHost")
|
|
138
|
+
return cluster
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def frontier(self) -> Frontier:
|
|
142
|
+
"""Instance-wide ordered log of durable effects; ``await self.frontier.wait()`` is the durable receipt."""
|
|
143
|
+
durability = self._ctx.durability
|
|
144
|
+
if durability is None:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
"durability requires a state store (Worker(state_store=...))"
|
|
147
|
+
)
|
|
148
|
+
assert durability.frontier is not None
|
|
149
|
+
return durability.frontier
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def placement_epoch(self) -> int:
|
|
153
|
+
"""The placement epoch this activation writes at (the ``pinboard-epoch`` header, 0 without a pinboard); follows an in-place epoch advance."""
|
|
154
|
+
durability = self._ctx.durability
|
|
155
|
+
if durability is None:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
"placement_epoch requires a state store (Worker(state_store=...))"
|
|
158
|
+
)
|
|
159
|
+
return durability.epoch
|
|
160
|
+
|
|
161
|
+
def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
|
|
162
|
+
return self._ctx.create_task(coro)
|
|
163
|
+
|
|
164
|
+
async def eviction_checkpoint(self) -> None:
|
|
165
|
+
"""Yield unfinished execution for relocation when this instance is winding down."""
|
|
166
|
+
if self.lease.winding_down:
|
|
167
|
+
raise EvictionRequested()
|
|
168
|
+
|
|
169
|
+
async def lifespan(self) -> AsyncIterator[None]:
|
|
170
|
+
yield
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if TYPE_CHECKING:
|
|
174
|
+
# The seam, proved structurally: the concrete types satisfy the Protocols a
|
|
175
|
+
# future zero-dependency host would depend on instead of these classes.
|
|
176
|
+
def _assert_conformance(ctx: PinboardContext, api: PinboardAPI) -> None:
|
|
177
|
+
_c: "HostableContext" = ctx
|
|
178
|
+
_h: "Hostable" = api
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The ambient-primitive seam: clocks, sleep, timed waits, task spawn and randomness.
|
|
2
|
+
|
|
3
|
+
Every reading of the world in pinned goes through a ``Domain``, threaded from
|
|
4
|
+
the worker down. ``RealDomain`` is the process; a simulation installs its own
|
|
5
|
+
to run the same code on virtual time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
import random
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Coroutine, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class Domain(Protocol):
|
|
18
|
+
"""Ambient primitives, injected: wall clock, monotonic clock, sleep, timed waits, task spawn, seeded RNG streams, nonces."""
|
|
19
|
+
|
|
20
|
+
def now(self) -> float: ...
|
|
21
|
+
|
|
22
|
+
def monotonic(self) -> float: ...
|
|
23
|
+
|
|
24
|
+
async def sleep(self, seconds: float) -> None: ...
|
|
25
|
+
|
|
26
|
+
async def wait(self, event: asyncio.Event, timeout: float) -> bool: ...
|
|
27
|
+
|
|
28
|
+
def spawn(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]": ...
|
|
29
|
+
|
|
30
|
+
def rng(self, label: str) -> random.Random: ...
|
|
31
|
+
|
|
32
|
+
def token_hex(self, nbytes: int) -> str: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RealDomain:
|
|
36
|
+
"""The process Domain: system clocks, the running event loop, per-label RNG streams off one seed."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, seed: int = 0) -> None:
|
|
39
|
+
if not isinstance(seed, int) or isinstance(seed, bool):
|
|
40
|
+
raise TypeError(f"seed must be an int: {seed!r}")
|
|
41
|
+
self._seed = seed
|
|
42
|
+
self._rngs: dict[str, random.Random] = {}
|
|
43
|
+
|
|
44
|
+
def now(self) -> float:
|
|
45
|
+
return time.time()
|
|
46
|
+
|
|
47
|
+
def monotonic(self) -> float:
|
|
48
|
+
return time.monotonic()
|
|
49
|
+
|
|
50
|
+
async def sleep(self, seconds: float) -> None:
|
|
51
|
+
await asyncio.sleep(seconds)
|
|
52
|
+
|
|
53
|
+
async def wait(self, event: asyncio.Event, timeout: float) -> bool:
|
|
54
|
+
# A cancel landing exactly as the timeout fires surfaces as
|
|
55
|
+
# TimeoutError; callers read the event, not the outcome of the race.
|
|
56
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
57
|
+
await asyncio.wait_for(event.wait(), timeout)
|
|
58
|
+
return event.is_set()
|
|
59
|
+
|
|
60
|
+
def spawn(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
|
|
61
|
+
return asyncio.create_task(coro)
|
|
62
|
+
|
|
63
|
+
def rng(self, label: str) -> random.Random:
|
|
64
|
+
# One substream per label: a new call site cannot shift another's draws.
|
|
65
|
+
if not label:
|
|
66
|
+
raise ValueError("an rng stream needs a non-empty label")
|
|
67
|
+
stream = self._rngs.get(label)
|
|
68
|
+
if stream is None:
|
|
69
|
+
stream = self._rngs[label] = random.Random(f"{self._seed}:{label}")
|
|
70
|
+
return stream
|
|
71
|
+
|
|
72
|
+
def token_hex(self, nbytes: int) -> str:
|
|
73
|
+
return secrets.token_hex(nbytes)
|