Skip to content

Commit b98b0d4

Browse files
tillrohrmannclaude
andcommitted
[Limiter] Add admin rule book endpoints and push to local cache
Two batch endpoints write the cluster-global rule book through read_modify_write, mirroring `RuleBook::apply_changes` so a request either commits in full or leaves the book untouched: * `PUT /limits/rules` — body is a list of `UpsertRuleRequest`s. Each entry carries a fully-specified rule body plus an optional `Precondition` (internally-tagged enum, defaults to `none`): - `{ "type": "none" }` → unconditional upsert, - `{ "type": "matches", "version": v }` → reject unless the rule's current version is `v`, - `{ "type": "does_not_exist" }` → strict insert. Returns the post-batch state of every entry so callers chaining `Matches(v)` get the new versions without a second read. * `POST /limits/rules/bulk-delete` — body is a list of `{ pattern, expected_version: Option<u32> }`. Missing version is an unconditional, idempotent delete; present version maps to `Precondition::Matches(v)`. Returns the patterns this batch actually removed (idempotent no-ops are omitted). `Precondition` is consumed directly by the request DTOs. To make that possible, this commit also opts the limiter's `serde` and `schema` features into deriving Serialize/Deserialize/ToSchema on `Precondition`, and gives `restate_types::Version` a transparent `utoipa::ToSchema` derive (`#[schema(value_type = u32)]`) under the existing `utoipa-schema` feature, so the OpenAPI spec sees Version as a plain integer. When a worker role runs in the same process, both handlers also push the freshly written book into the local `RuleBookCache` via a fire-and-forget observer threaded down from the node wiring, shaving the metadata-store poll latency. Errors collapse to three variants — `PreconditionFailed` (409), `CapExceeded` (422), and `MetadataStore` (500). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 235c629 commit b98b0d4

24 files changed

Lines changed: 492 additions & 40 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/admin-rest-model/Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@ publish = false
99

1010
[features]
1111
default = []
12-
schema = ["dep:utoipa", "restate-serde-util/utoipa-schema", "restate-types/utoipa-schema"]
12+
schema = [
13+
"dep:utoipa",
14+
"restate-limiter/schema",
15+
"restate-serde-util/utoipa-schema",
16+
"restate-types/utoipa-schema",
17+
]
1318

1419
[dependencies]
1520
restate-workspace-hack = { workspace = true }
1621

22+
restate-limiter = { workspace = true, features = ["rule-book", "serde"] }
1723
restate-types = { workspace = true }
1824
restate-serde-util = { workspace = true }
1925
restate-time-util = { workspace = true, features = ["serde_with"] }
26+
restate-util-string = { workspace = true }
2027

2128
bytes = { workspace = true }
2229
derive_more = { workspace = true, features = ["try_from"] }

crates/admin-rest-model/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod handlers;
1313
pub mod invocations;
1414
pub mod kafka_clusters;
1515
pub mod query;
16+
pub mod rules;
1617
pub mod services;
1718
pub mod subscriptions;
1819
pub mod version;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2023 - 2026 Restate Software, Inc., Restate GmbH.
2+
// All rights reserved.
3+
//
4+
// Use of this software is governed by the Business Source License
5+
// included in the LICENSE file.
6+
//
7+
// As of the Change Date specified in that file, in accordance with
8+
// the Business Source License, use of this software will be governed
9+
// by the Apache License, Version 2.0.
10+
11+
use serde::{Deserialize, Serialize};
12+
use serde_with::serde_as;
13+
14+
use restate_limiter::{PersistedRule, Precondition, RulePattern, UserLimits};
15+
use restate_types::Version;
16+
use restate_util_string::ReString;
17+
18+
/// One entry in the body of `PUT /limits/rules`.
19+
///
20+
/// Each entry carries a fully-specified rule body plus an optional
21+
/// [`Precondition`]. Omitting the `precondition` field defaults to
22+
/// `Precondition::None` (unconditional upsert).
23+
#[serde_as]
24+
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
25+
#[derive(Debug, Deserialize)]
26+
pub struct UpsertRuleRequest {
27+
/// The pattern that selects which scope/limit-key combinations the
28+
/// rule applies to. Examples: `"*"`, `"scope1/*"`, `"scope1/foo/bar"`.
29+
#[cfg_attr(feature = "schema", schema(value_type = String))]
30+
#[serde_as(as = "serde_with::DisplayFromStr")]
31+
pub pattern: RulePattern<ReString>,
32+
#[serde(default)]
33+
pub limits: UserLimits,
34+
/// Free-form description shown in the rule book; not consulted at
35+
/// runtime.
36+
#[serde(default, skip_serializing_if = "Option::is_none")]
37+
pub reason: Option<String>,
38+
/// Soft-tombstone toggle. `true` parks the rule (the runtime treats
39+
/// it as absent) without removing it.
40+
#[serde(default)]
41+
pub disabled: bool,
42+
/// Optimistic-concurrency guard. `{ "type": "matches", "version": v }`
43+
/// requires the rule's current version to be `v`;
44+
/// `{ "type": "does_not_exist" }` requires the rule to be absent
45+
/// (strict insert); `{ "type": "none" }` (or omitted) is
46+
/// unconditional.
47+
#[serde(default)]
48+
pub precondition: Precondition,
49+
}
50+
51+
/// One entry in the body of `POST /limits/rules/bulk-delete`.
52+
#[serde_as]
53+
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
54+
#[derive(Debug, Deserialize)]
55+
pub struct DeleteRuleRequest {
56+
#[cfg_attr(feature = "schema", schema(value_type = String))]
57+
#[serde_as(as = "serde_with::DisplayFromStr")]
58+
pub pattern: RulePattern<ReString>,
59+
/// Optimistic-concurrency match. Absent → unconditional delete
60+
/// (idempotent: deleting an already-absent rule succeeds as a
61+
/// no-op). Present → reject unless the rule's current version is
62+
/// the supplied value.
63+
#[cfg_attr(feature = "schema", schema(value_type = Option<u32>))]
64+
#[serde(default, skip_serializing_if = "Option::is_none")]
65+
pub expected_version: Option<Version>,
66+
}
67+
68+
#[serde_as]
69+
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
70+
#[derive(Debug, Serialize)]
71+
pub struct RuleResponse {
72+
#[cfg_attr(feature = "schema", schema(value_type = String))]
73+
#[serde_as(as = "serde_with::DisplayFromStr")]
74+
pub pattern: RulePattern<ReString>,
75+
pub limits: UserLimits,
76+
#[serde(skip_serializing_if = "Option::is_none")]
77+
pub reason: Option<String>,
78+
pub disabled: bool,
79+
/// Per-rule version: bumped on runtime-relevant changes.
80+
#[cfg_attr(feature = "schema", schema(value_type = u32))]
81+
pub version: Version,
82+
/// Seconds since UNIX epoch.
83+
pub last_modified_seconds_since_epoch: u64,
84+
}
85+
86+
impl From<(RulePattern<ReString>, &PersistedRule)> for RuleResponse {
87+
fn from((pattern, rule): (RulePattern<ReString>, &PersistedRule)) -> Self {
88+
RuleResponse {
89+
pattern,
90+
limits: rule.limits.clone(),
91+
reason: rule.reason.clone(),
92+
disabled: rule.disabled,
93+
version: rule.version,
94+
last_modified_seconds_since_epoch: rule.last_modified.as_unix_seconds(),
95+
}
96+
}
97+
}

crates/admin/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ restate-bifrost = { workspace = true, features = ["local-loglet", "replicated-lo
2828
restate-core = { workspace = true }
2929
restate-errors = { workspace = true }
3030
restate-ingestion-client = { workspace = true }
31+
restate-limiter = { workspace = true, features = ["rule-book"] }
3132
restate-metadata-store = { workspace = true }
3233
restate-metadata-providers = { workspace = true }
34+
restate-serde-util = { workspace = true }
3335
restate-service-client = { workspace = true }
3436
restate-service-protocol-v4 = { workspace = true, features = ["discovery", "serdes"] }
3537
restate-storage-query-datafusion = { workspace = true }
@@ -63,6 +65,7 @@ prost-dto = { workspace = true }
6365
rand = { workspace = true }
6466
serde = { workspace = true }
6567
serde_json = { workspace = true }
68+
serde_with = { workspace = true }
6669
thiserror = { workspace = true }
6770
tokio = { workspace = true }
6871
tonic = { workspace = true, features = ["transport", "codegen", "gzip", "zstd"] }

crates/admin/src/rest_api/cluster_health.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ use restate_core::{Metadata, my_node_id};
1717
use restate_types::config::Configuration;
1818
use restate_types::{NodeId, PlainNodeId};
1919

20-
use crate::rest_api::error::{ErrorDescriptionResponse, GenericRestError};
20+
use crate::rest_api::ErrorDescriptionResponse;
21+
use crate::rest_api::error::GenericRestError;
2122

2223
/// Cluster state endpoint
2324
#[utoipa::path(

crates/admin/src/rest_api/deployments.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
// the Business Source License, use of this software will be governed
99
// by the Apache License, Version 2.0.
1010

11-
use super::error::*;
12-
use crate::state::AdminServiceState;
1311
use std::time::SystemTime;
1412

1513
use axum::extract::{Path, Query, State};
1614
use axum::http::{StatusCode, header};
1715
use axum::response::IntoResponse;
1816
use axum::{Extension, Json};
1917
use http::{Method, Uri};
18+
use serde::Deserialize;
19+
2020
use restate_admin_rest_model::deployments::*;
2121
use restate_admin_rest_model::version::AdminApiVersion;
2222
use restate_errors::warn_it;
@@ -29,7 +29,10 @@ use restate_types::schema::registry::{
2929
Overwrite, TelemetryClient,
3030
};
3131
use restate_types::schema::service::ServiceMetadata;
32-
use serde::Deserialize;
32+
33+
use super::error::*;
34+
use crate::rest_api::ErrorDescriptionResponse;
35+
use crate::state::AdminServiceState;
3336

3437
/// Register deployment
3538
///

crates/admin/src/rest_api/error.rs

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@
88
// the Business Source License, use of this software will be governed
99
// by the Apache License, Version 2.0.
1010

11+
use std::ops::RangeInclusive;
12+
1113
use axum::Json;
1214
use axum::http::StatusCode;
1315
use axum::response::{IntoResponse, Response};
1416
use codederror::{Code, CodedError};
17+
1518
use restate_core::ShutdownError;
1619
use restate_types::identifiers::{DeploymentId, SubscriptionId};
1720
use restate_types::invocation::ServiceType;
1821
use restate_types::schema::registry::SchemaRegistryError;
1922
use restate_util_string::RestrictedValueError;
20-
use serde::Serialize;
21-
use std::ops::RangeInclusive;
23+
24+
use crate::rest_api::ErrorDescriptionResponse;
25+
2226
// --- Few helpers to define Admin API errors.
2327

2428
/// Macro to generate an Admin API Error enum with the given variants.
@@ -293,18 +297,6 @@ pub enum MetaApiError {
293297
BadScope(RestrictedValueError),
294298
}
295299

296-
/// # Error description response
297-
///
298-
/// Error details of the response
299-
#[derive(Debug, Serialize, utoipa::ToSchema)]
300-
pub(crate) struct ErrorDescriptionResponse {
301-
message: String,
302-
/// # Restate code
303-
///
304-
/// Restate error code describing this error
305-
restate_code: Option<&'static str>,
306-
}
307-
308300
impl IntoResponse for MetaApiError {
309301
fn into_response(self) -> Response {
310302
let status_code = match &self {
@@ -369,7 +361,8 @@ impl utoipa::IntoResponses for MetaApiError {
369361

370362
pub mod meta_api_error {
371363
//! Those types are only used to generate the corresponding OpenAPI specification for error types
372-
//! that are referenced by [`super::MetaApiError`] when calling [`utoipa::IntoResponses`].
364+
//! that are referenced by [`crate::rest_api::error::MetaApiError`] and [`crate::rest_api::rules::RulesApiError`]
365+
//! when calling [`utoipa::IntoResponses`].
373366
#![allow(dead_code)]
374367

375368
/// Bad request
@@ -388,6 +381,10 @@ pub mod meta_api_error {
388381
#[derive(utoipa::ToResponse)]
389382
pub struct Conflict(super::ErrorDescriptionResponse);
390383

384+
/// Unprocessable entity
385+
#[derive(utoipa::ToResponse)]
386+
pub struct UnprocessableEntity(super::ErrorDescriptionResponse);
387+
391388
/// Internal server error
392389
#[derive(utoipa::ToResponse)]
393390
pub struct InternalServerError(super::ErrorDescriptionResponse);

crates/admin/src/rest_api/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ mod health;
1818
mod invocations;
1919
mod kafka_clusters;
2020
mod query;
21+
mod rules;
2122
mod serdes;
2223
mod services;
2324
mod subscriptions;
2425
mod version;
2526

27+
use serde::Serialize;
2628
use utoipa::OpenApi;
2729
use utoipa_axum::{router::OpenApiRouter, routes};
2830

@@ -61,12 +63,14 @@ pub use version::{MAX_ADMIN_API_VERSION, MIN_ADMIN_API_VERSION};
6163
(name = "health", description = "Admin API health"),
6264
(name = "version", description = "API Version"),
6365
(name = "introspection", description = "System introspection"),
66+
(name = "rule", description = "Limiter rule book management"),
6467
),
6568
components(responses(
6669
error::meta_api_error::BadRequest,
6770
error::meta_api_error::NotFound,
6871
error::meta_api_error::MethodNotAllowed,
6972
error::meta_api_error::Conflict,
73+
error::meta_api_error::UnprocessableEntity,
7074
error::meta_api_error::InternalServerError))
7175
)]
7276
struct AdminApiDoc;
@@ -122,6 +126,9 @@ where
122126
.routes(routes!(kafka_clusters::get_kafka_cluster))
123127
.routes(routes!(kafka_clusters::update_kafka_cluster))
124128
.routes(routes!(kafka_clusters::delete_kafka_cluster))
129+
// Rule book endpoints
130+
.routes(routes!(rules::upsert_rules))
131+
.routes(routes!(rules::delete_rules))
125132
// Query endpoint
126133
.routes(routes!(query::query))
127134
};
@@ -189,3 +196,15 @@ fn create_envelope_header(partition_key: PartitionKey) -> Header {
189196
},
190197
}
191198
}
199+
200+
/// # Error description response
201+
///
202+
/// Error details of the response
203+
#[derive(Debug, Serialize, utoipa::ToSchema)]
204+
struct ErrorDescriptionResponse {
205+
message: String,
206+
/// # Restate code
207+
///
208+
/// Restate error code describing this error
209+
restate_code: Option<&'static str>,
210+
}

0 commit comments

Comments
 (0)