Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 245 additions & 90 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ members = [
"lib/global-discovery-service",
"lib/global-frontend",
"lib/global-router",
"lib/global-router-store-dynamodb",
"lib/global-view-broadcaster",
"lib/global-view-receiver",
"lib/router-plugins/catalog",
Expand Down Expand Up @@ -82,6 +83,7 @@ dynamo-data-gen = { path = "lib/data-gen", version = "1.7.0" }
dynamo-kv-hashing = { path = "lib/kv-hashing", version = "1.7.0" }
dynamo-global-common = { path = "lib/global-common", version = "1.7.0" }
dynamo-global-router = { path = "lib/global-router", version = "1.7.0" }
dynamo-global-router-store-dynamodb = { path = "lib/global-router-store-dynamodb", version = "1.7.0" }
dynamo-http-server = { path = "lib/http-server", version = "1.7.0" }
dynamo-memory = { path = "lib/memory", version = "1.7.0" }
dynamo-mocker = { path = "lib/mocker", version = "1.7.0" }
Expand All @@ -108,6 +110,8 @@ lru = { version = "0.16" }
async-nats = { version = "0.49.1", features = ["service"] }
async-stream = { version = "0.3" }
async-trait = { version = "0.1" }
aws-config = { version = "1.13.0" }
aws-sdk-dynamodb = { version = "1.132.0" }
blake3 = { version = "1.8.7" }
bytes = { version = "1.9" }
chrono = { version = "0.4", default-features = false, features = [
Expand Down
6 changes: 5 additions & 1 deletion lib/global-frontend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ path = "src/main.rs"
[dependencies]
dynamo-global-common = { workspace = true }
dynamo-global-router = { workspace = true }
dynamo-global-router-store-dynamodb = { workspace = true }
# For the PoolRelay protocol and the session header.
dynamo-llm = { workspace = true }
dynamo-runtime = { workspace = true }

anyhow = { workspace = true }
async-trait = { workspace = true }
aws-config = { workspace = true }
aws-sdk-dynamodb = { workspace = true }
axum = { workspace = true }
clap = { version = "4.5.49", features = ["derive"] }
clap = { version = "4.5.49", features = ["derive", "env"] }
futures = { workspace = true }
hyper-util = { workspace = true, features = ["server-auto", "service", "tokio"] }
prometheus = { workspace = true }
Expand Down
117 changes: 114 additions & 3 deletions lib/global-frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ session or a request without a session ID, to the least full pool in the
nearest region. It goes elsewhere only when another pool has more than
`--headroom-factor` (default 2) times that pool's spare capacity, choosing the
nearest such pool, or when that pool is full, choosing the nearest pool with any
room. The session's pin moves with it. Pins are per process and expire after
`--session-idle-secs` (default 1800) without a request.
room. The session's pin moves with it. Pins expire after
`--session-idle-secs` (default 1800) without a request, and are held in this
process unless `--affinity-store dynamodb` shares them (see
[Session affinity storage](#session-affinity-storage)).

`affinity` needs `--region`, this process's region named as `--pool` names
clusters. It reads each pool's load from the pool's PoolRelay, given per pool
Expand All @@ -79,14 +81,123 @@ load is known. The round trip to each region is the median TCP connect time to
its pools, measured every 30 seconds; the request's own region counts as
zero.

### Session affinity storage

`--affinity-store` decides where `affinity`'s session pins live. `memory`, the
default, keeps them in this process: a restart or a second replica starts with
none. `dynamodb` keeps them in a DynamoDB table, so replicas of this binary
agree on a session's pool and a restart resumes where it left off. No other
component reads or writes the table, and nothing else in Dynamo depends on it.

Affinity is advisory throughout. A lookup or write that fails, or that runs
past `--affinity-timeout-ms` (default 20), leaves the request on the pool the
headroom rules already chose: no request is refused because storage is down.
After `--affinity-breaker-failures` consecutive failures (default 5) the
frontend stops consulting storage for `--affinity-breaker-cooldown-ms` (default
5000), so an outage costs one deadline per cooldown rather than one per
request; a single request then retests it. Reads are cached in this process for
`--affinity-cache-ttl-ms` (default 1000, 0 to disable) across at most
`--affinity-cache-entries` sessions (default 100000), so a busy session reads
the table about once per TTL rather than once per request. A cached entry that
another replica has since changed loses its conditional write and is re-read,
so the cache costs a retry, never a wrong pin.

#### Table

Provision the table yourself; the frontend never creates it. It needs a string
partition key `itemHashKey`, a string sort key `sortKey`, and TTL enabled on
`expiresAt`. On-demand capacity suits the access pattern, which is one
`GetItem` and one `PutItem` per cache miss. A Global Table (MREC) uses exactly
this schema: conditional writes then serialize against the contacted regional
replica only, so two regions can briefly choose different pools for one session
until replication converges. That is soft affinity, not a global lock.

```bash
aws dynamodb create-table --table-name dynamo-global-affinity \
--billing-mode PAY_PER_REQUEST \
--attribute-definitions \
AttributeName=itemHashKey,AttributeType=S \
AttributeName=sortKey,AttributeType=S \
--key-schema \
AttributeName=itemHashKey,KeyType=HASH \
AttributeName=sortKey,KeyType=RANGE
aws dynamodb update-time-to-live --table-name dynamo-global-affinity \
--time-to-live-specification Enabled=true,AttributeName=expiresAt
```

Rows hold `poolId`, `revision`, `updatedAt`, `expiresAt` and `schemaVersion`.
Keys are BLAKE3 digests, so no session ID, credential or model name is stored
in the clear. A row that no reader can decode — a newer `schemaVersion`, or a
damaged write — is replaced by the next request for that session rather than
stranding it, which is also what lets a mixed-version rollout recover on its
own. TTL deletion is DynamoDB's own, within its usual delay; a row past
`expiresAt` is treated as expired on read whether or not it has been swept.

#### Scope and tenancy

`--affinity-scope` is the namespace bindings are shared in, and every replica
that should agree must pass the same one. It is deployment configuration and is
never derived from a request. `--affinity-policy` (default `headroom-v1`)
separates incompatible placement policies within a scope, so rolling out a new
policy does not inherit the old one's pins.

Within a scope, a session's binding is also keyed by a digest of the request's
`authorization` header, so two tenants that reuse one session ID never share a
binding; requests with no credential share one `anonymous` namespace. Note what
this is not: the Global Frontend does not validate credentials, and selection
happens before the pool authorizes the request, so a request the pool will
reject can still create or refresh a binding. Treat the table as writable by
anyone who can reach the frontend, and keep the frontend behind whatever admits
traffic to the deployment. Give each tenant its own `--affinity-scope` where
their bindings must not share a table at all.

#### IAM

The frontend's role needs only item access on the table:

```json
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:<region>:<account>:table/dynamo-global-affinity"
}
```

Credentials and region come from the standard AWS provider chain, with
`--affinity-ddb-region` overriding the region and `--affinity-ddb-endpoint`
pointing at DynamoDB Local for development. `--affinity-consistent-reads`
(default true) asks for strongly consistent reads within the contacted region.

#### Starting it

```bash
cargo run -p dynamo-global-frontend -- --port 8000 \
--router-policy affinity --region ohio \
--pool ohio/dynamo/blue=Qwen/Qwen3-0.6B@http://127.0.0.1:8001 \
--pool oregon/dynamo/green=Qwen/Qwen3-0.6B@http://127.0.0.1:8002 \
--pool-relay ohio/dynamo/blue=http://127.0.0.1:50051 \
--pool-relay oregon/dynamo/green=http://127.0.0.1:50052 \
--affinity-store dynamodb \
--affinity-scope production \
--affinity-ddb-table dynamo-global-affinity
```

Every flag has an environment variable (`DYN_GLOBAL_AFFINITY_STORE`,
`DYN_GLOBAL_AFFINITY_SCOPE`, `DYN_GLOBAL_AFFINITY_DDB_TABLE` and the rest), for
deployments that configure the frontend through the environment.

## Metrics

`/metrics`, on the same port, serves the Global Frontend's Prometheus metrics,
named `dynamo_global_frontend_*`: requests per pool by response status,
`no_response` when the pool sends no response, or `cancelled` when the client
leaves before the pool's response headers arrive; requests answered without a
pool; time to first byte and duration per pool; and requests in flight per
pool. The `pool` label is the pool's
pool. With `--router-policy affinity` it also serves
`affinity_lookups_total` by `outcome` — `hit`, `miss`, `conflict`, `error`,
`timeout`, `skipped` (the breaker was open, so storage was never consulted) and
`repair` — and `affinity_cache_total` by whether the in-process cache answered
the lookup. The `pool` label is the pool's
`<cluster>/<namespace>/<DGD name>` and `region` its region. On requests, `local`
says whether the pool is in `--region` (`unknown` without it), which gives the
share of requests kept in the region. Nothing is labelled from the request
Expand Down
Loading
Loading