Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
146 commits
Select commit Hold shift + click to select a range
7ba851a
fix(api): raise the body limit on the push routes, and only those
albertovincenzi Sep 4, 2026
a4faf85
chore: standardize JavaScript runtime on Node 24
albertovincenzi Sep 4, 2026
5aac094
ci: run Docker builds for the default branch
albertovincenzi Sep 4, 2026
0e357bd
fix(console): report node backlog in graph detail
albertovincenzi Sep 4, 2026
77a3f52
fix(runtime): fail graph closed when a stage exits
albertovincenzi Sep 4, 2026
c25cbde
fix(api): reject payload shapes Gate would discard
albertovincenzi Sep 4, 2026
ae7c11d
fix(relay): charge shared counters once per message
albertovincenzi Sep 4, 2026
a311a9a
fix(api): shed on any applicable full budget
albertovincenzi Sep 4, 2026
86bd481
fix(console): derive pacing from current backlog
albertovincenzi Sep 4, 2026
eafd23e
fix(console): emit saturating for growing backlog
albertovincenzi Sep 4, 2026
71c7d25
fix(history): checkpoint counters only after commit
albertovincenzi Sep 4, 2026
c87dee4
fix(history): calculate utilisation in cost units
albertovincenzi Sep 4, 2026
07f1cc8
fix(auth): constrain OAuth next redirects to local paths
albertovincenzi Sep 4, 2026
1615ff6
fix(console): expose budget provenance consistently
albertovincenzi Sep 4, 2026
2fbb4c4
fix(console): normalize trace budget field names
albertovincenzi Sep 4, 2026
e65ff95
fix(console): preserve unknown budget utilisation
albertovincenzi Sep 4, 2026
92e2303
fix(console): serialize polling refreshes
albertovincenzi Sep 4, 2026
d55da1d
fix(history): align rollups to completed minutes
albertovincenzi Sep 4, 2026
49a1412
fix(api): enforce bounded reentry attempts
albertovincenzi Sep 4, 2026
fc606d7
fix(relay): fail closed on cost overflow
albertovincenzi Sep 4, 2026
111256b
fix(api): refuse ambiguous flat deletes
albertovincenzi Sep 4, 2026
c0d7bb7
fix(store): delete dotted v1 target sources
albertovincenzi Sep 4, 2026
3a0a443
fix(relay): enforce applicable scoped budgets
albertovincenzi Sep 4, 2026
b7849c0
fix(supervisor): preserve egress queue configuration
albertovincenzi Sep 4, 2026
3b155c6
fix(breaker): trip counters and record atomically
albertovincenzi Sep 4, 2026
0e5cbd7
fix(store): paginate persisted graph scans
albertovincenzi Sep 4, 2026
6b0460a
fix(sync): never reap after a partial refusal
albertovincenzi Sep 4, 2026
e4e98d8
fix(console): bound history and event queries
albertovincenzi Sep 4, 2026
a9915c1
fix(auth): bind OAuth state to the initiating browser
albertovincenzi Sep 4, 2026
69063bf
fix(graph): reject queue feedback and source aliases
albertovincenzi Sep 4, 2026
fd03acc
fix(validation): require a node-wide breaker budget
albertovincenzi Sep 4, 2026
6709100
fix(budgets): exclude conditional limits from aggregates
albertovincenzi Sep 4, 2026
1ffa187
fix(validation): reject unsupported counter windows
albertovincenzi Sep 4, 2026
0713953
fix(payload): reserve Gate routing metadata paths
albertovincenzi Sep 4, 2026
42acb63
fix(budgets): make counter keys collision-free
albertovincenzi Sep 4, 2026
7f8e3d4
fix(auth): generate OAuth nonces from system entropy
albertovincenzi Sep 4, 2026
6f27834
fix(auth): protect logout from cross-site requests
albertovincenzi Sep 4, 2026
9eeaa10
fix(declare): enforce stored version bumps
albertovincenzi Sep 4, 2026
9975913
fix(breaker): bound atomic operation width
albertovincenzi Sep 4, 2026
cc95b6d
fix(validation): avoid window warning overflow
albertovincenzi Sep 4, 2026
7b24e6f
fix(eta): saturate extreme schedule arithmetic
albertovincenzi Sep 4, 2026
776f61e
fix(console): reload reused graph editor routes
albertovincenzi Sep 4, 2026
79147aa
fix(sync): reap from stored target inventory
albertovincenzi Sep 4, 2026
bb56cfe
fix: bound graph consumer workers
albertovincenzi Sep 4, 2026
e63baf2
fix: preserve legacy console route suffixes
albertovincenzi Sep 4, 2026
79e69de
fix: follow graph path query changes
albertovincenzi Sep 4, 2026
8b4c098
fix: route overview polling through single flight
albertovincenzi Sep 4, 2026
ee7bc3a
fix(history): prune inactive counter checkpoints
albertovincenzi Sep 4, 2026
36f2077
fix(migrate): saturate legacy period arithmetic
albertovincenzi Sep 4, 2026
bb7b20e
fix: confirm persistence during reconciliation
albertovincenzi Sep 4, 2026
17d535c
test: isolate store-write failures from reads
albertovincenzi Sep 4, 2026
a3cd403
fix: fail closed when source ownership is unknown
albertovincenzi Sep 4, 2026
d045af4
fix: reject costs outside the broker integer range
albertovincenzi Sep 4, 2026
6994cd2
fix: reject unstampable shared routing
albertovincenzi Sep 4, 2026
61f0b68
fix: refresh JWKS for unknown key ids
albertovincenzi Sep 4, 2026
a5d5f9e
fix: validate shared breaker records
albertovincenzi Sep 4, 2026
91b69aa
fix: fail closed on unreadable live state
albertovincenzi Sep 4, 2026
1358297
fix: fail closed on history read errors
albertovincenzi Sep 4, 2026
59b9926
fix: fail closed on unreadable depth state
albertovincenzi Sep 5, 2026
51393f6
test: isolate restore failure from inventory check
albertovincenzi Sep 5, 2026
031beb4
fix: reject malformed budget counter state
albertovincenzi Sep 5, 2026
7924add
fix(ui): surface telemetry read failures
albertovincenzi Sep 5, 2026
67a088d
test: account for Queen depth retries
albertovincenzi Sep 5, 2026
7601c07
test: corrupt the shared budget counter under test
albertovincenzi Sep 5, 2026
657342b
fix: count only committed head settlements
albertovincenzi Sep 5, 2026
ecfcf1f
fix(declare): keep a stored graph running when a later rule would ref…
albertovincenzi Sep 5, 2026
1f63725
fix(console): let the shared-budget bar say "unknown" too
albertovincenzi Sep 5, 2026
f783804
fix(console): read the edit suffix from the route, not from the URL
albertovincenzi Sep 5, 2026
8bb152f
fix(console): a failed reload must not leave the previous document de…
albertovincenzi Sep 5, 2026
1d32e9e
fix(budgets): keep the migration's sentinel out of the node-wide aggr…
albertovincenzi Sep 5, 2026
522aa79
fix(relay): record the cost of an item settled one at a time
albertovincenzi Sep 5, 2026
0b92473
fix(budget): an unreadable expiry must not fail the charge
albertovincenzi Sep 5, 2026
7fbdd20
fix(api): an omitted payload is not a payload being erased
albertovincenzi Sep 5, 2026
332fd8f
fix(sync): ask the runtime, not the store, whether a name is a target
albertovincenzi Sep 5, 2026
51b3558
fix(declare): scope the incomplete-inventory refusal to queues Gate d…
albertovincenzi Sep 5, 2026
5627dde
fix(validation): a shared queue is a cycle only when work returns to it
albertovincenzi Sep 5, 2026
67bc33a
fix(console): saturating needs a rise, not a wobble
albertovincenzi Sep 5, 2026
2e1fe41
fix(console): a history outage must not hide whether the broker is up
albertovincenzi Sep 5, 2026
d8e5645
fix(api): cap the push body knob
albertovincenzi Sep 5, 2026
3790d88
test(sync): prove the OMITTED target survives a partial refusal
albertovincenzi Sep 5, 2026
d5f2748
fix(ui): distinguish auth bootstrap failures
albertovincenzi Sep 5, 2026
afc8f87
Merge remote-tracking branch 'pr/3' into integ-v2
cincrement Sep 7, 2026
e34a51e
Merge remote-tracking branch 'pr/5' into integ-v2
cincrement Sep 7, 2026
c45065d
Merge remote-tracking branch 'pr/6' into integ-v2
cincrement Sep 7, 2026
9db7272
Merge remote-tracking branch 'pr/8' into integ-v2
cincrement Sep 7, 2026
1196191
Merge remote-tracking branch 'pr/9' into integ-v2
cincrement Sep 7, 2026
a3c7ef4
Merge remote-tracking branch 'pr/15' into integ-v2
cincrement Sep 7, 2026
3ce8290
Merge remote-tracking branch 'pr/16' into integ-v2
cincrement Sep 7, 2026
cca39b0
Merge remote-tracking branch 'pr/17' into integ-v2
cincrement Sep 7, 2026
2159c83
Merge remote-tracking branch 'pr/18' into integ-v2
cincrement Sep 7, 2026
751e172
Merge remote-tracking branch 'pr/19' into integ-v2
cincrement Sep 7, 2026
40de58f
Merge remote-tracking branch 'pr/23' into integ-v2
cincrement Sep 7, 2026
21d9c52
Merge remote-tracking branch 'pr/26' into integ-v2
cincrement Sep 7, 2026
114c420
Merge remote-tracking branch 'pr/27' into integ-v2
cincrement Sep 7, 2026
2f15083
Merge remote-tracking branch 'pr/43' into integ-v2
cincrement Sep 7, 2026
6b021ef
Merge remote-tracking branch 'pr/45' into integ-v2
cincrement Sep 7, 2026
7c87b9d
Merge remote-tracking branch 'pr/48' into integ-v2
cincrement Sep 7, 2026
928b096
Merge remote-tracking branch 'pr/49' into integ-v2
cincrement Sep 7, 2026
6ef0db1
Merge remote-tracking branch 'pr/53' into integ-v2
cincrement Sep 7, 2026
493fbbd
Merge remote-tracking branch 'pr/55' into integ-v2
cincrement Sep 7, 2026
800fcf3
Merge remote-tracking branch 'pr/63' into integ-v2
cincrement Sep 7, 2026
bf991ae
Merge remote-tracking branch 'pr/65' into integ-v2
cincrement Sep 7, 2026
ba939c5
Merge remote-tracking branch 'pr/11' into integ-v2
cincrement Sep 7, 2026
11e3623
Merge remote-tracking branch 'pr/21' into integ-v2
cincrement Sep 7, 2026
cdacac3
Merge remote-tracking branch 'pr/22' into integ-v2
cincrement Sep 7, 2026
24916cb
Merge remote-tracking branch 'pr/31' into integ-v2
cincrement Sep 7, 2026
e358a25
Merge remote-tracking branch 'pr/39' into integ-v2
cincrement Sep 7, 2026
cb00de1
Merge remote-tracking branch 'pr/52' into integ-v2
cincrement Sep 7, 2026
9fef0ee
Merge remote-tracking branch 'pr/40' into integ-v2
cincrement Sep 7, 2026
d6b3aeb
Merge remote-tracking branch 'pr/57' into integ-v2
cincrement Sep 7, 2026
4cf4c0e
Merge remote-tracking branch 'pr/64' into integ-v2
cincrement Sep 7, 2026
cdf4958
Merge remote-tracking branch 'pr/50' into integ-v2
cincrement Sep 7, 2026
216eb13
Merge remote-tracking branch 'pr/24' into integ-v2
cincrement Sep 7, 2026
54130ac
Merge remote-tracking branch 'pr/28' into integ-v2
cincrement Sep 7, 2026
95d47c7
Merge remote-tracking branch 'pr/29' into integ-v2
cincrement Sep 7, 2026
df227bb
Merge remote-tracking branch 'pr/66' into integ-v2
cincrement Sep 7, 2026
d1df7df
Merge remote-tracking branch 'pr/46' into integ-v2
cincrement Sep 7, 2026
bb8a506
Merge remote-tracking branch 'pr/34' into integ-v2
cincrement Sep 7, 2026
59fd0b9
Merge remote-tracking branch 'pr/35' into integ-v2
cincrement Sep 7, 2026
bbb71ef
Merge remote-tracking branch 'pr/36' into integ-v2
cincrement Sep 7, 2026
f9a5c86
Merge remote-tracking branch 'pr/37' into integ-v2
cincrement Sep 7, 2026
0303b97
Merge remote-tracking branch 'pr/42' into integ-v2
cincrement Sep 7, 2026
de3e5d4
Merge remote-tracking branch 'pr/47' into integ-v2
cincrement Sep 7, 2026
10dd887
Merge remote-tracking branch 'pr/32' into integ-v2
cincrement Sep 7, 2026
24319b7
Merge remote-tracking branch 'pr/54' into integ-v2
cincrement Sep 7, 2026
b4a8441
Merge remote-tracking branch 'pr/10' into integ-v2
cincrement Sep 7, 2026
697db87
Merge remote-tracking branch 'pr/25' into integ-v2
cincrement Sep 7, 2026
7e561b5
Merge remote-tracking branch 'pr/56' into integ-v2
cincrement Sep 7, 2026
2af268b
Merge remote-tracking branch 'pr/38' into integ-v2
cincrement Sep 7, 2026
e67cb29
Merge remote-tracking branch 'pr/41' into integ-v2
cincrement Sep 7, 2026
4719118
Merge remote-tracking branch 'pr/13' into integ-v2
cincrement Sep 7, 2026
a1e10fe
Merge remote-tracking branch 'pr/20' into integ-v2
cincrement Sep 7, 2026
7f96f1a
Merge remote-tracking branch 'pr/51' into integ-v2
cincrement Sep 7, 2026
4c7099b
Merge remote-tracking branch 'pr/12' into integ-v2
cincrement Sep 7, 2026
0056214
Merge remote-tracking branch 'pr/30' into integ-v2
cincrement Sep 7, 2026
d092f2f
Merge remote-tracking branch 'pr/44' into integ-v2
cincrement Sep 7, 2026
3ca7421
Merge remote-tracking branch 'pr/58' into integ-v2
cincrement Sep 7, 2026
9cfc503
Merge remote-tracking branch 'pr/59' into integ-v2
cincrement Sep 7, 2026
7d3cf36
Merge remote-tracking branch 'pr/61' into integ-v2
cincrement Sep 7, 2026
49c2ce7
Merge remote-tracking branch 'pr/62' into integ-v2
cincrement Sep 7, 2026
a11340b
Merge remote-tracking branch 'pr/60' into integ-v2
cincrement Sep 7, 2026
dac72ed
integ: propagate depth read failures in the per-node view
cincrement Sep 7, 2026
45eed29
Merge remote-tracking branch 'pr/14' into integ-v2
cincrement Sep 7, 2026
63b04bd
integ: restore a #[test] lost in the #38 resolution, and rustfmt
cincrement Sep 7, 2026
a6353c6
ci: run the test workflow on pull requests against every base
cincrement Sep 7, 2026
39a8d4d
integ: a stored document over the worker cap is refused, not kept
cincrement Sep 7, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ name: Docker Image

on:
push:
branches: [ "main" ]
branches: [ "master" ]
tags: [ "v*" ]
pull_request:
branches: [ "main" ]
branches: [ "master" ]
workflow_dispatch:

# One run per ref: a tag push landing on top of a branch push should supersede
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
# Every base, not only master. A PR stacked on another feature branch is
# otherwise never checked at all: its first build is the merge into master,
# which is the one moment nobody wants to learn it does not compile.
branches: [ '**' ]
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -74,7 +77,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: npm
cache-dependency-path: ui/package-lock.json

Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 40 additions & 20 deletions DESIGN_GATE_V2.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# Run: docker run -p 8788:8788 -e QUEEN_URL=http://queen:6632 gate

# ---------------------------------------------------------------- the console
FROM node:22-alpine AS ui-builder
FROM node:24-alpine AS ui-builder

WORKDIR /app/ui
COPY ui/package*.json ./
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,10 @@ reaches it — and `GATE_PUBLIC_BIND` requires a Google session on every route.
the local sign-in bypass and Gate refuses to boot with it set on an `https` public URL;
`GATE_ADMIN_EMAILS` is what makes that identity able to write rather than only read.

Building from source requires Node.js 24 for the embedded console; the root `.nvmrc` selects it.

```bash
nvm use # Node.js 24, from the root .nvmrc
cd ui && npm ci && npm run build && cd .. # the console is compiled into the binary
cargo build --release --workspace
cargo test --workspace # the live suite reports as ignored
Expand All @@ -209,6 +212,7 @@ with no broker configured, which is green lines that verified nothing. CI sets
| `GATE_MAX_PARK_MS` | 30000 | how long a handler holds its claim waiting for a window before releasing |
| `GATE_INTERIOR_SEED_SKEW_SECONDS` | 120 | how far before a graph's start a new group on an **interior** queue is seeded; a margin for Gate's clock against the broker's, capped at 600 |
| `GATE_RECONCILE_SECONDS` | 15 | how often a replica re-reads the store |
| `GATE_MAX_PUSH_BODY_BYTES` | 8388608 | the largest body a **push** route buffers, clamped to 2 MiB–64 MiB. 2 MiB is axum's default, which is what applied to everything until 2026-09-04 because nothing set one; the ceiling is there because the limit is a per-request memory reservation and nothing bounds how many requests hold one at once. Document routes keep the default |

**Where a new consumer group starts, and it is two rules.** On an **ingress** queue — yours, or
Gate's own HTTP front door — a new group is seeded at the *head* of the retained log, because a
Expand All @@ -231,8 +235,9 @@ there are counters (`popped`, `admitted`, `deferred`, `parked`, `released`, `for
explains a stage's throughput. `wedged` is the one to alert on: it counts a stage whose ack the
broker keeps refusing at a claim head that never moves, which is a stuck cursor and not a budget
backlog — the stage says so once at `ERROR` with the `seek` that fixes it. Denials are kept in a bounded in-process ring; admissions are counted, never
traced. Rollups are opt-in per graph (`"counters": { "windowSeconds": 60 }`), because observability
is a thing you switch on, not a thing that runs whether or not anyone is looking.
traced. Rollups are opt-in per graph (`"counters": { "windowSeconds": 60 }`); the current storage
and API contract is a fixed one-minute window, so `60` is the only accepted value. Observability is
a thing you switch on, not a thing that runs whether or not anyone is looking.

**One thing to say out loud.** The declaration names your egress queue, and:

Expand Down
103 changes: 91 additions & 12 deletions crates/core/src/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

use serde_json::Value;

use crate::doc::{Cost, PAYLOAD_ROOT};
use crate::doc::{Cost, GATE_META, PAYLOAD_ROOT};
use crate::plan::CompiledBudget;

/// Walk a dotted payload path. The first segment must be `payload`, which names
/// the message's own `data`; `payload.a.b` is `data["a"]["b"]`.
Expand All @@ -21,22 +22,30 @@ pub fn resolve<'a>(data: &'a Value, path: &str) -> Option<&'a Value> {
if segs.next()? != PAYLOAD_ROOT {
return None;
}
let mut cur = data;
let first = segs.next()?;
if first.is_empty() || first == GATE_META {
return None;
}
let mut cur = data.get(first)?;
for s in segs {
if s.is_empty() {
return None;
}
cur = cur.get(s)?;
}
Some(cur)
}

/// Whether a string is a usable payload path: `payload` plus at least one
/// segment, each of them non-empty.
/// non-empty segment. Gate's root `_gate` envelope is deliberately outside the
/// declaration language: costs and scopes may only come from producer data.
pub fn ok_payload_path(path: &str) -> bool {
let mut segs = path.split('.');
if segs.next() != Some(PAYLOAD_ROOT) {
return false;
}
let rest: Vec<&str> = segs.collect();
!rest.is_empty() && rest.iter().all(|s| !s.is_empty())
!rest.is_empty() && rest.first() != Some(&GATE_META) && rest.iter().all(|s| !s.is_empty())
}

/// The scope value a budget keys on, as it reaches the kv key.
Expand All @@ -54,6 +63,32 @@ pub fn scope_value(data: &Value, path: &str) -> Option<String> {
}
}

/// The first applicable scoped budget whose key cannot be resolved.
///
/// Applicability comes first: a `photo.delete` per-listing budget has no reason
/// to require `listingId` from a `photo.upload`. Both the HTTP door and the
/// relay use this one answer so direct queue ingress cannot enforce a different
/// contract from HTTP ingress.
pub fn missing_scope<'a>(
budgets: &'a [CompiledBudget],
data: &Value,
) -> Option<(&'a str, &'a str)> {
let op = op_of(data);
budgets.iter().find_map(|budget| {
if budget
.when_op
.as_ref()
.is_some_and(|patterns| !op_matches(patterns, op))
{
return None;
}
let path = budget.scope_by.as_deref()?;
scope_value(data, path)
.is_none()
.then_some((budget.id.as_str(), path))
})
}

/// What this item costs, or the reason it can never be admitted.
///
/// Integers, because `kv.incr`'s delta is `i64` on this wire. A resolved cost
Expand All @@ -69,10 +104,22 @@ pub fn cost_of(cost: &Cost, data: &Value) -> Result<i64, TooExpensive> {
Cost::Fixed(n) => (*n, *n),
Cost::Path(c) => {
let max = c.max.unwrap_or(c.default);
let v = resolve(data, &c.path)
.and_then(integral)
.filter(|n| *n >= 1)
.unwrap_or(c.default);
let v = match resolve(data, &c.path) {
Some(value) => match integral(value) {
Ok(value) => value.filter(|n| *n >= 1).unwrap_or(c.default),
// `i64::MAX` is the largest lower bound the public error
// type can carry. It is enough to refuse every ordinary
// maximum; equality is the sentinel for an out-of-range
// positive number and gets its own truthful message below.
Err(()) => {
return Err(TooExpensive {
cost: i64::MAX,
max,
})
}
},
None => c.default,
};
(v, max)
}
};
Expand All @@ -94,6 +141,13 @@ pub struct TooExpensive {

impl std::fmt::Display for TooExpensive {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.cost == i64::MAX && self.max == i64::MAX {
return write!(
f,
"this item declares a cost outside the signed 64-bit range the broker can charge: \
refusing it is safer than silently charging i64::MAX"
);
}
write!(
f,
"this item declares a cost of {} and the node admits at most {}: an item that cannot \
Expand All @@ -105,13 +159,38 @@ impl std::fmt::Display for TooExpensive {
}

/// A JSON number that is a whole number. `3.0` is three; `3.5` is not a cost.
fn integral(v: &Value) -> Option<i64> {
///
/// A whole number outside `i64` is an error rather than a missing value. Rust's
/// float-to-integer cast saturates, so treating it as an ordinary conversion
/// would collapse every larger JSON number to `i64::MAX` and undercharge it.
fn integral(v: &Value) -> Result<Option<i64>, ()> {
match v {
Value::Number(n) => match n.as_i64() {
Some(i) => Some(i),
None => n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64),
Some(i) => Ok(Some(i)),
None => {
let Some(f) = n.as_f64() else {
return Ok(None);
};
if f.fract() != 0.0 {
return Ok(None);
}
// `i64::MAX as f64` rounds to 2^63, one past the largest i64,
// so the upper bound is deliberately exclusive. The lower one
// is inclusive because -2^63 is representable.
const I64_BOUND: f64 = 9_223_372_036_854_775_808.0;
if f >= I64_BOUND {
return Err(());
}
// A negative value already means "use the default". Keep that
// tolerance even when its magnitude is outside i64; unlike an
// oversized positive cost, it cannot make Gate undercharge.
if f < -I64_BOUND {
return Ok(None);
}
Ok(Some(f as i64))
}
},
_ => None,
_ => Ok(None),
}
}

Expand Down
15 changes: 10 additions & 5 deletions crates/core/src/doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,16 @@ pub struct Counters {
pub window_seconds: u32,
}

/// Roll-ups are stored in minute-keyed rows and every history endpoint reads
/// those rows as minutes. Keep the one supported value named in one place so a
/// declaration cannot promise a window the runtime does not actually emit.
pub const COUNTERS_WINDOW_SECONDS: u32 = 60;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct Node {
/// At least one, and at least one of them unscoped — see `node-budget` and
/// `node-unscoped-budget`.
/// At least one, and at least one of them unconditional and unscoped — see
/// `node-budget` and `node-unscoped-budget`.
#[serde(default)]
pub budgets: Vec<Budget>,

Expand All @@ -129,9 +134,9 @@ pub struct Node {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch: Option<u32>,

/// How many workers drain this node's stages. Defaults to
/// `max(4, source partitions)`. More workers than partitions is harmless
/// (the extras find nothing and park); fewer is a throughput ceiling.
/// How many workers drain this node's stages. By default this is derived
/// from the tightest unconditional, unscoped rate and capped at the source
/// partition count; an explicit value overrides that derivation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency: Option<u32>,
}
Expand Down
13 changes: 8 additions & 5 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ pub mod plan;
pub mod v1;
pub mod validate;

pub use cost::{cost_of, ok_payload_path, op_matches, op_of, resolve, scope_value, TooExpensive};
pub use cost::{
cost_of, missing_scope, ok_payload_path, op_matches, op_of, resolve, scope_value, TooExpensive,
};
pub use doc::{
default_application, ok_name, ok_target_name, Budget, Confidence, Cost, CostPath, Counters,
Egress, EgressSpec, GraphDoc, Ingress, IngressSpec, Node, Path, PathElem, GATE_META,
PAYLOAD_ROOT,
Egress, EgressSpec, GraphDoc, Ingress, IngressSpec, Node, Path, PathElem,
COUNTERS_WINDOW_SECONDS, GATE_META, PAYLOAD_ROOT,
};
pub use ids::derive;
pub use plan::{
Expand All @@ -34,6 +36,7 @@ pub use plan::{
ASSUMED_FACTOR, DEFAULT_BATCH, DEFAULT_INGRESS_PARTITIONS,
};
pub use validate::{
needs_version_bump, validate, validate_with, warnings, warnings_with, ExternalFacts, Problem,
QueueFacts,
needs_version_bump, refuses_stored_document, validate, validate_plan_with, validate_with,
warnings, warnings_with, ExternalFacts, Problem, QueueFacts, MAX_BREAKER_COUNTERS,
MAX_GRAPH_WORKERS,
};
54 changes: 41 additions & 13 deletions crates/core/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,26 @@ fn rolling_sub_windows(time_ms: i64, count: i64, cost_max: i64) -> u32 {
}

fn budget(b: &v1::Budget, cost_max: i64, out: &mut Vec<Problem>, node: &str) -> Budget {
let time_ms = b.period_seconds.max(1) * 1000;
let seconds = b.period_seconds.max(1);
// v1 carries seconds in an i64 while v2 carries milliseconds in one. The
// multiplication can therefore overflow for a document that is perfectly
// valid on the old wire. Keep the migration total and say explicitly when
// the destination type cannot represent the original duration.
let time_ms = seconds.saturating_mul(1000);
if seconds > i64::MAX / 1000 {
out.push(w(
"period-clamped",
format!(
"budget `{}` of node `{node}` declares periodSeconds {}. The v2 `timeMs` field \
cannot represent that many milliseconds, so it was capped at {}ms instead of \
overflowing the migration. Lower the period and redeclare if this budget is \
intended to rotate on an operational timescale.",
b.id,
b.period_seconds,
i64::MAX
),
));
}
let count = (b.cap.floor() as i64).max(1);

let sub_windows = match b.alignment {
Expand All @@ -125,8 +144,8 @@ fn budget(b: &v1::Budget, cost_max: i64, out: &mut Vec<Problem>, node: &str) ->
admitted.",
b.id,
(time_ms / n.max(1) as i64) / 1000,
2 * (count / n.max(1) as i64).max(1),
2 * count
(count / n.max(1) as i64).max(1).saturating_mul(2),
count.saturating_mul(2)
),
));
Some(n)
Expand Down Expand Up @@ -227,9 +246,9 @@ fn budget(b: &v1::Budget, cost_max: i64, out: &mut Vec<Problem>, node: &str) ->
/// Two v1 shapes land here. A **class node** with an out-edge was allowed to
/// declare no budget at all: it existed to isolate a traffic class and carry a
/// priority, and the limit it was checked against lived downstream. And a node
/// with only SCOPED budgets was legal too — v1's ETA read the worst key and its
/// breach ring was per-replica, so neither needed a node-level denominator; v2's
/// ETA and its breaker both do.
/// with only SCOPED or CONDITIONAL budgets was legal too — v1's ETA read the
/// worst key and its breach ring was per-replica, so neither needed a counter
/// every item meets; v2's ETA and its breaker both do.
///
/// Either way the mapping declares a pass-through — which limits nothing,
/// exactly as before — and says so loudly, rather than inventing a ceiling
Expand All @@ -238,11 +257,12 @@ fn passthrough_budget(node: &str, out: &mut Vec<Problem>) -> Budget {
out.push(w(
"node-budget",
format!(
"node `{node}` declared no budget on the node itself (v1 allowed that for a class \
node, and for a node carrying only per-key budgets). v2 requires one — it is what \
the ETA measures a rate against and what the breaker spends when a vendor says 429 \
— so a pass-through of 1000000 per second has been declared for it: it limits \
nothing, exactly as before. Replace it with the real limit."
"node `{node}` declared no unconditional budget on the node itself (v1 allowed that \
for a class node, and for a node carrying only per-key or conditional budgets). v2 \
requires one — it is what the ETA measures a rate against and what the breaker \
spends when a vendor says 429 — so a pass-through of 1000000 per second has been \
declared for it: it limits nothing, exactly as before. Replace it with the real \
limit."
),
));
Budget {
Expand Down Expand Up @@ -392,7 +412,11 @@ pub fn from_v1_target(spec: &v1::TargetSpec) -> Result<Migrated, Refused> {
// NOT mapped — see `lane_concurrency_warning`.
concurrency: None,
};
if node.budgets.iter().all(|b| b.scope_by.is_some()) {
if node
.budgets
.iter()
.all(|b| b.scope_by.is_some() || b.when_op.is_some())
{
node.budgets.push(passthrough_budget(&node_name, &mut out));
}
lane_concurrency_warning(&lanes, &node_name, &mut out);
Expand Down Expand Up @@ -480,7 +504,11 @@ pub fn from_v1_graph(spec: &v1::GraphSpec) -> Result<Migrated, Refused> {
// NOT mapped — see `lane_concurrency_warning`.
concurrency: None,
};
if node.budgets.iter().all(|b| b.scope_by.is_some()) {
if node
.budgets
.iter()
.all(|b| b.scope_by.is_some() || b.when_op.is_some())
{
node.budgets.push(passthrough_budget(name, &mut out));
}
if node.egress.is_some() {
Expand Down
Loading
Loading