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.
- pinned-0.1.0/.gitignore +9 -0
- pinned-0.1.0/PKG-INFO +98 -0
- pinned-0.1.0/README.md +84 -0
- pinned-0.1.0/pyproject.toml +21 -0
- pinned-0.1.0/src/pinned/__init__.py +16 -0
- pinned-0.1.0/src/pinned/api.py +83 -0
- pinned-0.1.0/src/pinned/directory.py +475 -0
- pinned-0.1.0/src/pinned/host.py +345 -0
- pinned-0.1.0/src/pinned/hostable.py +71 -0
- pinned-0.1.0/src/pinned/lease.py +198 -0
- pinned-0.1.0/src/pinned/metrics.py +49 -0
- pinned-0.1.0/src/pinned/registration.py +286 -0
- pinned-0.1.0/src/pinned/routing.py +48 -0
- pinned-0.1.0/tests/pinned_helpers.py +98 -0
- pinned-0.1.0/tests/test_control_plane.py +234 -0
- pinned-0.1.0/tests/test_cp_push.py +140 -0
- pinned-0.1.0/tests/test_create_task.py +60 -0
- pinned-0.1.0/tests/test_drain.py +176 -0
- pinned-0.1.0/tests/test_hostable.py +20 -0
- pinned-0.1.0/tests/test_id_validation.py +49 -0
- pinned-0.1.0/tests/test_lease.py +146 -0
- pinned-0.1.0/tests/test_lifecycle.py +184 -0
- pinned-0.1.0/tests/test_pinboard_integration.py +170 -0
- pinned-0.1.0/tests/test_registration.py +390 -0
- pinned-0.1.0/tests/test_streaming.py +140 -0
- pinned-0.1.0/tests/test_websocket.py +101 -0
- pinned-0.1.0/tests/test_wind_down.py +299 -0
pinned-0.1.0/.gitignore
ADDED
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
|