pinned 0.1.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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ uv.lock
pinned-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: pinned
3
+ Version: 0.1.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
+ Provides-Extra: psutil
12
+ Requires-Dist: psutil>=5.9; extra == 'psutil'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Pinned
16
+
17
+ Durable Objects for Python.
18
+
19
+ A PinnedAPI instance for a given id will exist exactly once in your fleet. It acts as a singleton webserver.
20
+
21
+ ## PinnedAPI
22
+
23
+ ```python
24
+ from pinned import PinnedAPI, route
25
+
26
+ class MyAPI(PinnedAPI):
27
+ async def lifespan(self):
28
+ # setup
29
+ yield
30
+ # teardown
31
+
32
+ @route.get("/hello")
33
+ async def hello(self):
34
+ return {"hello": self.id}
35
+ ```
36
+
37
+ - Handlers must be `async def`.
38
+ - Ids are client-generated; the first request activates.
39
+ - `@route.get/post/websocket` see the path remainder after the id.
40
+ - `self.create_task(coro)` runs background work bound to the instance: it blocks idle eviction while running, teardown waits for it, `abort()` cancels it.
41
+
42
+ ## PinnedHost
43
+
44
+ ```python
45
+ from pinned import PinnedHost
46
+
47
+ app = FastAPI()
48
+ app.include_router(
49
+ PinnedHost(
50
+ MyAPI,
51
+ pinboard_url="https://pinboard.internal", # omit -> local mode
52
+ advertise_url="http://10.0.3.7:8000/myapi", # must uniquely address THIS process
53
+ namespace="/myapi",
54
+ ),
55
+ prefix="/myapi",
56
+ )
57
+ ```
58
+
59
+ - `PinnedHost` is an APIRouter carrying `/{id}` that dispatches to the instance.
60
+ - Local mode uses an in-memory directory and speaks the same external API.
61
+
62
+ ## Pinboard
63
+
64
+ A reverse proxy that routes each request to the backend holding the instance, placing instances on registered backends as needed. Requires Redis and a deployment token shared with the backends.
65
+
66
+ ### Protocol
67
+
68
+ All registry calls carry `Authorization: Bearer <deployment token>`.
69
+
70
+ - `POST /registry/register` at boot — `{ clientId, secret, namespaces, advertiseUrl, headroom, draining }`. Response: `{ sessionId, expiresInMs }`.
71
+ - `POST /registry/heartbeat` every 3s — `{ sessionId, headroom, draining, drainDeadline? }`. Response: ack `{ expiresInMs }`, or a 409 NACK: `reregister` (unknown session — abort local instances, register fresh) or `superseded`.
72
+ - `POST /registry/release` — `{ clientId, namespace, id }` frees a placement when an instance closes.
73
+ - Pinboard → backend: `POST <advertiseUrl>/_pinned/drain` `{ deadline? }`, `/_pinned/wind_down` `{ id, deadline? }`, and `/_pinned/resume` `{ id? }`, authenticated with `x-pinned-proxy-secret`.
74
+ - Operator: `POST /registry/push` delivers a drain/wind_down/resume to a worker; `GET /registry/workers` and `GET /registry/overview` introspect; `GET /healthz` is unauthenticated.
75
+ - `clientId` and `secret` are minted once at process start. `sessionId` is the per-registration lease token: placements belong to the session, and the backend aborts all local instances before serving under a new one.
76
+ - Data-plane requests go over plain HTTP to `<advertiseUrl>/<id><rest>`. The proxy sets `x-pinned-proxy-secret` to the worker's secret; the backend rejects a mismatch with 403.
77
+ - Placement is per `(namespace, id)`: first claim wins (Redis `SET NX`) among live, undrained backends, biased toward headroom. Redis keys and body shapes are documented in pinboard's source (`packages/pinned/pinboard`).
78
+
79
+ ## Lifecycle
80
+
81
+ - Activation on first request; deactivation by idle sweep. In-flight requests and open streams block eviction.
82
+ - `self.abort()` force-ends an instance: cancels in-flight work and tears down.
83
+ - Streams are pull-based: the response generator advances only as the client reads — a slow client backpressures its own stream and nothing else, with no per-connection buffer.
84
+ - Draining workers answer new activations with 503 + Retry-After while existing requests keep serving; freed ids activate on the new deployment.
85
+ - While an instance's close and `/registry/release` are in flight, requests for that id answer 503 + Retry-After so the proxy re-resolves the placement instead of re-activating locally.
86
+
87
+ **Instance memory is ephemeral.** Idle eviction, deploys, lease loss, and crashes all discard it; a re-activation starts from `lifespan` with nothing. Anything that must survive belongs in the app's own store, written before the response that claims it.
88
+
89
+ ## Leases and fencing
90
+
91
+ - Each acked heartbeat anchors the lease at send time: `self.lease.expires_at` = send time + `expiresInMs` (10s by default, pinboard-supplied) — the moment pinboard may re-place this worker's ids, matching the worker record's Redis TTL.
92
+ - When `expires_at - buffer` passes without an ack, the worker quits: aborts every local instance and stops registering before the ids can move; the host app keeps running. The buffer (default 5s) is the worker's local safety margin; `self.lease.configure(...)` changes it and the handlers.
93
+ - A frozen process (VM pause, long GC) that wakes after its lease lapsed fails its next heartbeat (unknown session) and aborts everything before serving again.
94
+ - Guarantee: at most one live instance per id outside a lease-bounded failure window.
95
+
96
+ ## Observability
97
+
98
+ Lifecycle events (activation, eviction/abort, drain, lease health, registration) log on the stdlib `pinned.*` logger hierarchy with instance/namespace fields; configure it like any library logger. When `prometheus_client` is importable, `pinned.metrics` registers `pinned_*` counters, gauges, and a per-namespace request duration histogram on the default registry — a host app exposing `/metrics` picks them up automatically. Without the package they are no-ops. No Sentry or OpenTelemetry wiring; that stays in the host app.
pinned-0.1.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Pinned
2
+
3
+ Durable Objects for Python.
4
+
5
+ A PinnedAPI instance for a given id will exist exactly once in your fleet. It acts as a singleton webserver.
6
+
7
+ ## PinnedAPI
8
+
9
+ ```python
10
+ from pinned import PinnedAPI, route
11
+
12
+ class MyAPI(PinnedAPI):
13
+ async def lifespan(self):
14
+ # setup
15
+ yield
16
+ # teardown
17
+
18
+ @route.get("/hello")
19
+ async def hello(self):
20
+ return {"hello": self.id}
21
+ ```
22
+
23
+ - Handlers must be `async def`.
24
+ - Ids are client-generated; the first request activates.
25
+ - `@route.get/post/websocket` see the path remainder after the id.
26
+ - `self.create_task(coro)` runs background work bound to the instance: it blocks idle eviction while running, teardown waits for it, `abort()` cancels it.
27
+
28
+ ## PinnedHost
29
+
30
+ ```python
31
+ from pinned import PinnedHost
32
+
33
+ app = FastAPI()
34
+ app.include_router(
35
+ PinnedHost(
36
+ MyAPI,
37
+ pinboard_url="https://pinboard.internal", # omit -> local mode
38
+ advertise_url="http://10.0.3.7:8000/myapi", # must uniquely address THIS process
39
+ namespace="/myapi",
40
+ ),
41
+ prefix="/myapi",
42
+ )
43
+ ```
44
+
45
+ - `PinnedHost` is an APIRouter carrying `/{id}` that dispatches to the instance.
46
+ - Local mode uses an in-memory directory and speaks the same external API.
47
+
48
+ ## Pinboard
49
+
50
+ A reverse proxy that routes each request to the backend holding the instance, placing instances on registered backends as needed. Requires Redis and a deployment token shared with the backends.
51
+
52
+ ### Protocol
53
+
54
+ All registry calls carry `Authorization: Bearer <deployment token>`.
55
+
56
+ - `POST /registry/register` at boot — `{ clientId, secret, namespaces, advertiseUrl, headroom, draining }`. Response: `{ sessionId, expiresInMs }`.
57
+ - `POST /registry/heartbeat` every 3s — `{ sessionId, headroom, draining, drainDeadline? }`. Response: ack `{ expiresInMs }`, or a 409 NACK: `reregister` (unknown session — abort local instances, register fresh) or `superseded`.
58
+ - `POST /registry/release` — `{ clientId, namespace, id }` frees a placement when an instance closes.
59
+ - Pinboard → backend: `POST <advertiseUrl>/_pinned/drain` `{ deadline? }`, `/_pinned/wind_down` `{ id, deadline? }`, and `/_pinned/resume` `{ id? }`, authenticated with `x-pinned-proxy-secret`.
60
+ - Operator: `POST /registry/push` delivers a drain/wind_down/resume to a worker; `GET /registry/workers` and `GET /registry/overview` introspect; `GET /healthz` is unauthenticated.
61
+ - `clientId` and `secret` are minted once at process start. `sessionId` is the per-registration lease token: placements belong to the session, and the backend aborts all local instances before serving under a new one.
62
+ - Data-plane requests go over plain HTTP to `<advertiseUrl>/<id><rest>`. The proxy sets `x-pinned-proxy-secret` to the worker's secret; the backend rejects a mismatch with 403.
63
+ - Placement is per `(namespace, id)`: first claim wins (Redis `SET NX`) among live, undrained backends, biased toward headroom. Redis keys and body shapes are documented in pinboard's source (`packages/pinned/pinboard`).
64
+
65
+ ## Lifecycle
66
+
67
+ - Activation on first request; deactivation by idle sweep. In-flight requests and open streams block eviction.
68
+ - `self.abort()` force-ends an instance: cancels in-flight work and tears down.
69
+ - Streams are pull-based: the response generator advances only as the client reads — a slow client backpressures its own stream and nothing else, with no per-connection buffer.
70
+ - Draining workers answer new activations with 503 + Retry-After while existing requests keep serving; freed ids activate on the new deployment.
71
+ - While an instance's close and `/registry/release` are in flight, requests for that id answer 503 + Retry-After so the proxy re-resolves the placement instead of re-activating locally.
72
+
73
+ **Instance memory is ephemeral.** Idle eviction, deploys, lease loss, and crashes all discard it; a re-activation starts from `lifespan` with nothing. Anything that must survive belongs in the app's own store, written before the response that claims it.
74
+
75
+ ## Leases and fencing
76
+
77
+ - Each acked heartbeat anchors the lease at send time: `self.lease.expires_at` = send time + `expiresInMs` (10s by default, pinboard-supplied) — the moment pinboard may re-place this worker's ids, matching the worker record's Redis TTL.
78
+ - When `expires_at - buffer` passes without an ack, the worker quits: aborts every local instance and stops registering before the ids can move; the host app keeps running. The buffer (default 5s) is the worker's local safety margin; `self.lease.configure(...)` changes it and the handlers.
79
+ - A frozen process (VM pause, long GC) that wakes after its lease lapsed fails its next heartbeat (unknown session) and aborts everything before serving again.
80
+ - Guarantee: at most one live instance per id outside a lease-bounded failure window.
81
+
82
+ ## Observability
83
+
84
+ Lifecycle events (activation, eviction/abort, drain, lease health, registration) log on the stdlib `pinned.*` logger hierarchy with instance/namespace fields; configure it like any library logger. When `prometheus_client` is importable, `pinned.metrics` registers `pinned_*` counters, gauges, and a per-namespace request duration histogram on the default registry — a host app exposing `/metrics` picks them up automatically. Without the package they are no-ops. No Sentry or OpenTelemetry wiring; that stays in the host app.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pinned"
7
+ version = "0.1.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"]
13
+
14
+ [project.optional-dependencies]
15
+ psutil = ["psutil>=5.9"]
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/assistant-ui/harness-sdk"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/pinned"]
@@ -0,0 +1,16 @@
1
+ from .api import PinnedAPI, PinnedContext
2
+ from .host import PinnedHost
3
+ from .hostable import Hostable, HostableContext
4
+ from .lease import InstanceLease, Lease
5
+ from .routing import route
6
+
7
+ __all__ = [
8
+ "PinnedAPI",
9
+ "PinnedContext",
10
+ "PinnedHost",
11
+ "Hostable",
12
+ "HostableContext",
13
+ "InstanceLease",
14
+ "Lease",
15
+ "route",
16
+ ]
@@ -0,0 +1,83 @@
1
+ import asyncio
2
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Coroutine
3
+
4
+ from .lease import InstanceLease
5
+
6
+ if TYPE_CHECKING:
7
+ from .hostable import Hostable, HostableContext
8
+
9
+
10
+ class PinnedContext:
11
+ def __init__(
12
+ self,
13
+ id: str,
14
+ *,
15
+ lease: InstanceLease,
16
+ placement_token: str,
17
+ abort: Callable[[], None],
18
+ create_task: Callable[[Coroutine[Any, Any, Any]], "asyncio.Task[Any]"] | None = None,
19
+ unref: Callable[["asyncio.Task[Any]"], None] | None = None,
20
+ ) -> None:
21
+ self.id = id
22
+ self.lease = lease
23
+ self.placement_token = placement_token
24
+ self._abort = abort
25
+ self._create_task = create_task
26
+ self._unref = unref
27
+
28
+ def abort(self) -> None:
29
+ self._abort()
30
+
31
+ def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
32
+ if self._create_task is None:
33
+ return asyncio.create_task(coro)
34
+ return self._create_task(coro)
35
+
36
+ def unref(self, task: "asyncio.Task[Any]") -> None:
37
+ if self._unref is not None:
38
+ self._unref(task)
39
+
40
+
41
+ class PinnedAPI:
42
+ # The instance base depends only on the context interface, not the concrete
43
+ # PinnedContext — a PinnedAPI would host unchanged under any HostableContext.
44
+ def __init__(self, ctx: "HostableContext") -> None:
45
+ self.ctx = ctx
46
+
47
+ @property
48
+ def id(self) -> str:
49
+ return self.ctx.id
50
+
51
+ @property
52
+ def lease(self) -> InstanceLease:
53
+ return self.ctx.lease
54
+
55
+ @property
56
+ def placement_token(self) -> str:
57
+ """Random token minted at activation, stable until deactivation. Use it
58
+ as an activation lock in external systems: write on activate, compare
59
+ on every guarded write, treat an overwrite as loss of the placement."""
60
+ return self.ctx.placement_token
61
+
62
+ def abort(self) -> None:
63
+ self.ctx.abort()
64
+
65
+ def create_task(self, coro: Coroutine[Any, Any, Any]) -> "asyncio.Task[Any]":
66
+ return self.ctx.create_task(coro)
67
+
68
+ def unref(self, task: "asyncio.Task[Any]") -> "asyncio.Task[Any]":
69
+ """Mark background work as non-idle-blocking: the task keeps running
70
+ but no longer counts against idleness, and is cancelled at close."""
71
+ self.ctx.unref(task)
72
+ return task
73
+
74
+ async def lifespan(self) -> AsyncIterator[None]:
75
+ yield
76
+
77
+
78
+ if TYPE_CHECKING:
79
+ # The seam, proved structurally: the concrete types satisfy the Protocols a
80
+ # future zero-dependency host would depend on instead of these classes.
81
+ def _assert_conformance(ctx: PinnedContext, api: PinnedAPI) -> None:
82
+ _c: "HostableContext" = ctx
83
+ _h: "Hostable" = api