feat(sidecar): protocol 7 — the Event System's command plane (Phase 16b cutover, 1 of 6) #40
@@ -97,6 +97,16 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
.route("/world", post(world_spawn))
|
.route("/world", post(world_spawn))
|
||||||
.route("/world/:run_id", get(world_owned))
|
.route("/world/:run_id", get(world_owned))
|
||||||
.route("/world/:run_id/despawn", post(world_despawn))
|
.route("/world/:run_id/despawn", post(world_despawn))
|
||||||
|
// The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b). Neither owned nor
|
||||||
|
// borrowed: an item put into somebody's hands, and a world save. Both are `done is
|
||||||
|
// done`, which is why they are not in the world family -- there is nothing to give
|
||||||
|
// back and no ledger row core would come back for.
|
||||||
|
//
|
||||||
|
// `GET /items` is the shard's own grant allowlist, so the website's dropdown offers
|
||||||
|
// what this shard will actually build rather than what a module guessed.
|
||||||
|
.route("/items", get(item_catalog))
|
||||||
|
.route("/items/grant", post(item_grant))
|
||||||
|
.route("/world/save", post(world_save))
|
||||||
// Help-page (support) queue: snapshot the open queue, respond to / close a page.
|
// Help-page (support) queue: snapshot the open queue, respond to / close a page.
|
||||||
.route("/pages", get(pages_list))
|
.route("/pages", get(pages_list))
|
||||||
.route("/pages/:id/respond", post(page_respond))
|
.route("/pages/:id/respond", post(page_respond))
|
||||||
@@ -390,9 +400,23 @@ fn respond_event(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
|||||||
.unwrap_or("request rejected");
|
.unwrap_or("request rejected");
|
||||||
let code = if reason.contains("disabled") {
|
let code = if reason.contains("disabled") {
|
||||||
StatusCode::FORBIDDEN
|
StatusCode::FORBIDDEN
|
||||||
} else if reason.contains("no lease is offered") || reason.contains("not counting")
|
} else if reason.contains("no lease is offered")
|
||||||
|
|| reason.contains("not counting")
|
||||||
|
// Phase 12b. A grant against a run this shard has never been told to count
|
||||||
|
// is the same shape as an unknown lease key: the caller named something that
|
||||||
|
// does not exist here, which is a 404 and never a retry. It is deliberately
|
||||||
|
// NOT the same as a run whose ledger is open and empty -- that is a 200 with
|
||||||
|
// `granted: 0`, because "nobody came" is a result rather than a mistake.
|
||||||
|
|| reason.contains("no participation ledger")
|
||||||
{
|
{
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
|
} else if reason.contains("saves at most every") {
|
||||||
|
// A save refused because one just happened is the shard's rate limit, and it
|
||||||
|
// is TRANSIENT in a way nothing else on this plane is: the same request will
|
||||||
|
// succeed once the interval passes. 429 says exactly that, and keeps it out of
|
||||||
|
// the module's permanent-status set so a phase boundary is retried rather than
|
||||||
|
// abandoned.
|
||||||
|
StatusCode::TOO_MANY_REQUESTS
|
||||||
} else {
|
} else {
|
||||||
StatusCode::BAD_REQUEST
|
StatusCode::BAD_REQUEST
|
||||||
};
|
};
|
||||||
@@ -652,8 +676,36 @@ async fn event_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json
|
|||||||
/// overridden".** A lease whose deadline has already fired stays listed, with `expired: true`,
|
/// overridden".** A lease whose deadline has already fired stays listed, with `expired: true`,
|
||||||
/// until teardown collects its verdict -- otherwise a reconcile in that window would report it gone
|
/// until teardown collects its verdict -- otherwise a reconcile in that window would report it gone
|
||||||
/// and the website would write off a correctly-working backstop as an orphaned resource.
|
/// and the website would write off a correctly-working backstop as an orphaned resource.
|
||||||
async fn lease_list(State(st): State<AppState>) -> impl IntoResponse {
|
/// **`?key=` and `?target=` narrow it to one row, and a targeted key needs them** (protocol 7 part
|
||||||
event_call(&st, "lease.list", Value::Null).await
|
/// b). `Spawner.MaxCount` is one capability over thousands of spawners, so it has no single
|
||||||
|
/// "current" and the catalog walk cannot fill one in -- while the website's `read()` needs exactly
|
||||||
|
/// one value for exactly one target before it applies anything. Naming both answers that.
|
||||||
|
///
|
||||||
|
/// The frame also carries `holds`: every lease this shard is actually holding, whatever key or
|
||||||
|
/// target it is on. A catalog walk can enumerate the KEYS but never the holds on a targeted one --
|
||||||
|
/// there is no list of spawners to walk -- so without it a reconcile after an outage would have no
|
||||||
|
/// way to ask "what are you still holding?".
|
||||||
|
async fn lease_list(State(st): State<AppState>, Query(q): Query<LeaseQuery>) -> impl IntoResponse {
|
||||||
|
let mut body = serde_json::Map::new();
|
||||||
|
if let Some(key) = q.key {
|
||||||
|
body.insert("key".to_string(), json!(key));
|
||||||
|
}
|
||||||
|
if let Some(target) = q.target {
|
||||||
|
body.insert("target".to_string(), json!(target));
|
||||||
|
}
|
||||||
|
let arg = if body.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
Value::Object(body)
|
||||||
|
};
|
||||||
|
event_call(&st, "lease.list", arg).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Narrowing for `GET /lease`. Both optional: absent means the whole catalog, as before.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LeaseQuery {
|
||||||
|
key: Option<String>,
|
||||||
|
target: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body: {"key":"...","value":"...","holdMs":<ms>,"untilMs":<opt>,"runId":<opt>,"idempotencyKey":<opt>}.
|
/// Body: {"key":"...","value":"...","holdMs":<ms>,"untilMs":<opt>,"runId":<opt>,"idempotencyKey":<opt>}.
|
||||||
@@ -773,6 +825,51 @@ async fn world_despawn(
|
|||||||
event_call(&st, "world.despawn", Value::Object(obj)).await
|
event_call(&st, "world.despawn", Value::Object(obj)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the one-shots (protocol 7 part b) ----
|
||||||
|
|
||||||
|
/// What this shard is willing to grant, and the bounds it will grant within.
|
||||||
|
///
|
||||||
|
/// A read, so the website's option source offers what this shard will actually build. The module
|
||||||
|
/// holds the same list, which is two copies of a short allowlist on purpose and exactly how the
|
||||||
|
/// lease bounds are already carried: the module's copy is what makes a bad value a refusal on a
|
||||||
|
/// form, and this one is what is true when the website is wrong.
|
||||||
|
async fn item_catalog(State(st): State<AppState>) -> impl IntoResponse {
|
||||||
|
event_call(&st, "item.catalog", Value::Null).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"runId":"...","item":"gold","amount":N,"hue":<opt>,"name":<opt>,"where":<opt>,"idempotencyKey":<opt>}.
|
||||||
|
///
|
||||||
|
/// **The recipients are not in the body, and that is the design.** The shard already holds the
|
||||||
|
/// run's participation ledger (protocol 6 part b), keyed by the same character serials the
|
||||||
|
/// website's `member_key` holds, so the grant names a run and the shard resolves who was there.
|
||||||
|
/// Sending a list would mean the same list crossing the wire twice with a window in which the two
|
||||||
|
/// disagree -- and it would have needed a core surface handing a module core's own participants.
|
||||||
|
///
|
||||||
|
/// A run with no ledger open is a 404, not an empty success: "nobody came" and "you never told me
|
||||||
|
/// to count" are different facts, and only the first is a result a run should record.
|
||||||
|
///
|
||||||
|
/// **Retryable, and protocol 6 is why.** `EVENTS.md` §G called a grant un-retryable because a lost
|
||||||
|
/// acknowledgement and a grant that never applied looked the same -- exactly the argument that made
|
||||||
|
/// `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey` closes that: a repeat is
|
||||||
|
/// answered by the original reply, so a retried grant cannot be one winner receiving two.
|
||||||
|
async fn item_grant(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
event_call(&st, "item.grant", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body: {"idempotencyKey":<opt>}. Starts a world save, useful as a phase boundary.
|
||||||
|
///
|
||||||
|
/// The reply says the save was STARTED and nothing more. What actually happened rides
|
||||||
|
/// `world.save.before` / `world.save.after`, which have been on the event stream since protocol 2 --
|
||||||
|
/// so this route asserts nothing it cannot know, and a caller that needs the completion watches the
|
||||||
|
/// stream it is already connected to.
|
||||||
|
///
|
||||||
|
/// **A save too soon after the last one is refused, not queued**, and the shard counts ServUO's own
|
||||||
|
/// autosave as the last one. A save stops the world; a queued one would land at a moment nobody
|
||||||
|
/// chose, in the middle of whatever the next step is doing.
|
||||||
|
async fn world_save(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||||
|
event_call(&st, "world.save", body).await
|
||||||
|
}
|
||||||
|
|
||||||
// ---- help-page queue handlers ----
|
// ---- help-page queue handlers ----
|
||||||
|
|
||||||
/// The open help-page queue, correlated on reqId. Returns a pages.list.
|
/// The open help-page queue, correlated on reqId. Returns a pages.list.
|
||||||
@@ -1401,6 +1498,68 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A run this shard was never told to count is a 404; a run that WAS counted and had no
|
||||||
|
/// attendees is a 200. Protocol 7 part b.
|
||||||
|
#[test]
|
||||||
|
fn an_uncounted_run_is_a_404_and_an_empty_one_is_not() {
|
||||||
|
assert_eq!(
|
||||||
|
respond_event(Ok(json!({
|
||||||
|
"kind": "oneshot.error",
|
||||||
|
"reason": "run 42 has no participation ledger open on this shard"
|
||||||
|
})))
|
||||||
|
.0,
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
// The distinction the 404 exists to preserve. "Nobody came" is a RESULT -- an event
|
||||||
|
// nobody attended still happened -- and answering it as a failure would have the module
|
||||||
|
// retry a grant against a ledger that will be just as empty next time.
|
||||||
|
assert_eq!(
|
||||||
|
respond_event(Ok(json!({
|
||||||
|
"kind": "item.grant.ok",
|
||||||
|
"runId": "42",
|
||||||
|
"granted": 0,
|
||||||
|
"missed": []
|
||||||
|
})))
|
||||||
|
.0,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The save rate limit is the one refusal on this plane that the same request will get past
|
||||||
|
/// by waiting, so it is a 429 rather than the 400 every other refusal is.
|
||||||
|
#[test]
|
||||||
|
fn a_save_refused_for_coming_too_soon_is_a_429() {
|
||||||
|
assert_eq!(
|
||||||
|
respond_event(Ok(json!({
|
||||||
|
"kind": "oneshot.error",
|
||||||
|
"reason": "this shard saves at most every 300 seconds, and the last save was 12 seconds ago"
|
||||||
|
})))
|
||||||
|
.0,
|
||||||
|
StatusCode::TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
// And an ordinary refusal on the same plane is still a 400, so the 429 is not swallowing
|
||||||
|
// the class it sits beside: a grant this shard does not offer will never succeed, however
|
||||||
|
// long the caller waits.
|
||||||
|
assert_eq!(
|
||||||
|
respond_event(Ok(json!({
|
||||||
|
"kind": "oneshot.error",
|
||||||
|
"reason": "this shard does not grant 'castle'"
|
||||||
|
})))
|
||||||
|
.0,
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
// The event gate being off stays a 403 on this plane too -- it is an operator's deliberate
|
||||||
|
// refusal, not a bad request.
|
||||||
|
assert_eq!(
|
||||||
|
respond_event(Ok(json!({
|
||||||
|
"kind": "oneshot.error",
|
||||||
|
"reason": "the event plane is disabled on this shard (Bridge.EventsEnabled)"
|
||||||
|
})))
|
||||||
|
.0,
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A lease taken, a tally answered: an ordinary success carries straight through.
|
/// A lease taken, a tally answered: an ordinary success carries straight through.
|
||||||
#[test]
|
#[test]
|
||||||
fn event_successes_are_200s() {
|
fn event_successes_are_200s() {
|
||||||
|
|||||||
Reference in New Issue
Block a user