Metadata-Version: 2.4
Name: pinned
Version: 0.2.0
Summary: One live instance of your class per id, with an HTTP surface
Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
License-Expression: MIT
Requires-Python: >=3.11
Requires-Dist: fastapi>=0.115
Requires-Dist: httpx>=0.27
Requires-Dist: starlette>=0.37
Provides-Extra: psutil
Requires-Dist: psutil>=5.9; extra == 'psutil'
Description-Content-Type: text/markdown

# Pinned

Durable Objects for Python.

A PinnedAPI instance for a given id will exist exactly once in your fleet. It acts as a singleton webserver.

## PinnedAPI

```python
from pinned import PinnedAPI, route

class MyAPI(PinnedAPI):
    async def lifespan(self):
        # setup
        yield
        # teardown

    @route.get("/hello")
    async def hello(self):
        return {"hello": self.id}
```

- Handlers must be `async def`.
- Ids are client-generated; the first request activates.
- `@route.get/post/websocket` see the path remainder after the id.
- `self.create_task(coro)` runs background work bound to the instance: it blocks idle eviction while running, teardown waits for it, `abort()` cancels it.

## PinnedHost

```python
from pinned import PinnedHost

app = FastAPI()
app.include_router(
    PinnedHost(
        MyAPI,
        pinboard_url="https://pinboard.internal",    # omit -> local mode
        advertise_url="http://10.0.3.7:8000/myapi",  # must uniquely address THIS process
        namespace="/myapi",
    ),
    prefix="/myapi",
)
```

- `PinnedHost` is an APIRouter carrying `/{id}` that dispatches to the instance.
- Local mode uses an in-memory directory and speaks the same external API.

## Pinboard

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.

### Protocol

All registry calls carry `Authorization: Bearer <deployment token>`.

- `POST /registry/register` at boot — `{ clientId, secret, namespaces, advertiseUrl, headroom, draining, challenge, challengeValue }`. Before accepting, pinboard calls `GET <advertiseUrl>/_pinned/challenge?challenge=<challenge>` (authenticated with `x-pinned-proxy-secret`) and requires the matching `{ value }`. Response: `{ sessionId, expiresInMs }`.
- `POST /registry/heartbeat` every 3s — `{ sessionId, headroom, draining, drainDeadline? }`. Response: ack `{ expiresInMs, messages?, reregister? }` — `messages` carries queued drain/wind_down/resume control messages, `reregister: true` tells an unverified worker to register again; or a 409 NACK: `reregister` (unknown session — abort local instances, register fresh) or `superseded`.
- `POST /registry/release` — `{ clientId, namespace, id }` frees a placement when an instance closes.
- Operator: `POST /registry/push` queues a drain/wind_down/resume for a worker (delivered on its next heartbeat ack); `GET /registry/workers` and `GET /registry/overview` introspect; `GET /healthz` is unauthenticated.
- `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.
- 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.
- 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`).

## Lifecycle

- Activation on first request; deactivation by idle sweep. In-flight requests and open streams block eviction.
- A host holds at most `max_instances` ids at once (default 10 000); activations beyond the cap answer 503 + Retry-After while existing instances keep serving.
- `self.abort()` force-ends an instance: cancels in-flight work and tears down.
- 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.
- Draining workers answer new activations with 503 + Retry-After while existing requests keep serving; freed ids activate on the new deployment.
- 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.

**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.

## Leases and fencing

- 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.
- 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.
- 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.
- Registration failures retry with exponential backoff (capped at 30s); only a `superseded` NACK — another session took over this worker — stops the loop for good. `host.ready` is False while the worker is unregistered or past its lease; wire it to the app's readiness probe (`pinned_registered` is the matching gauge).
- Guarantee: at most one live instance per id outside a lease-bounded failure window.

## Observability

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.
