Skip to content

Commit dc0d904

Browse files
authored
Merge pull request #76 from runcycles/release/v0.1.25.3
feat: v0.1.25.3 — extended runtime event emission
2 parents fe95f55 + 0a9e21c commit dc0d904

12 files changed

Lines changed: 443 additions & 19 deletions

File tree

AUDIT.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,44 @@
66

77
---
88

9+
### 2026-04-03 — v0.1.25.3: Extended runtime event emission + PROTOCOL_VERSION fix
10+
11+
**Version bump:** 0.1.25.1 → 0.1.25.3 (0.1.25.2 was the case-insensitive scope fix below).
12+
13+
**Fix:** `Enums.PROTOCOL_VERSION` was hardcoded to `"0.1.24"` — updated to `"0.1.25"` to match the current protocol spec.
14+
15+
**New runtime event emissions (protocol spec v0.1.25 Webhook Event Guidance):**
16+
17+
Added 4 new event types emitted from Java controllers (non-blocking, async via `EventEmitterService`):
18+
19+
| Event Type | Detection | Emit Location |
20+
|---|---|---|
21+
| `budget.exhausted` | `remaining.amount == 0` in any post-operation balance | ReservationController (reserve, commit), EventController |
22+
| `budget.over_limit_entered` | `is_over_limit == true` in any post-operation balance | ReservationController (reserve, commit), EventController |
23+
| `budget.debt_incurred` | `debt.amount > 0` in any post-operation balance | ReservationController (reserve, commit), EventController |
24+
| `reservation.expired` | Expiry sweeper Lua returns `EXPIRED` status | ReservationExpiryService (post-expire HGETALL for tenant/scope context) |
25+
26+
**Implementation approach:**
27+
- `EventEmitterService.emitBalanceEvents()` — new helper that inspects post-operation balances returned from Lua scripts. Called from ReservationController (after reserve and commit) and EventController (after event creation). No extra Redis calls — uses balances already on the response.
28+
- `ReservationExpiryService.emitExpiredEvent()` — after expire.lua succeeds, fetches the reservation hash (1 HGETALL) to get tenant_id, scope_path, estimate_amount, created_at_ms, expires_at_ms, extension_count for the event payload. Uses `ActorType.SYSTEM` since it's a background job.
29+
30+
**Events NOT emitted (deferred — require new infrastructure):**
31+
- `budget.threshold_crossed` — needs per-scope threshold configuration + utilization % calculation
32+
- `budget.burn_rate_anomaly` — needs time-series rate tracking subsystem
33+
- `budget.over_limit_exited` — admin-only event (triggered by funding operations in cycles-server-admin)
34+
35+
**Runtime event coverage:** 6 of 9 spec-suggested event types now emitted (was 2).
36+
37+
**Modified files:**
38+
- `Enums.java` — PROTOCOL_VERSION "0.1.24" → "0.1.25"
39+
- `EventEmitterService.java` — added `emitBalanceEvents()` helper
40+
- `ReservationController.java` — wired `emitBalanceEvents()` after reserve and commit
41+
- `EventController.java` — wired `emitBalanceEvents()` after event creation
42+
- `ReservationExpiryService.java` — added `emitExpiredEvent()` post-expire hook
43+
- `pom.xml` — revision 0.1.25.1 → 0.1.25.3
44+
45+
---
46+
947
### 2026-04-03 — v0.1.25.2: Case-insensitive scope matching
1048

1149
**Bug fix (defense-in-depth):** The admin API may have stored mixed-case scope values. `getBalances` lowercased query params but not the stored scope from Redis, causing case mismatches. Now lowercases `trueScope`/`scopePath` before segment matching in both `getBalances` and `listReservations`.

BENCHMARKS.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,58 @@ Run benchmarks: `mvn test -Pbenchmark` (requires Docker).
99

1010
---
1111

12+
## v0.1.25.3 — Extended Runtime Event Emission + PROTOCOL_VERSION Fix
13+
14+
**Date:** 2026-04-03
15+
**Branch:** `release/v0.1.25.3`
16+
**Base commit:** `32293e4`
17+
**Environment:** Windows 11 Pro, AMD Ryzen Threadripper 3990X 64-Core, Java 21.0.5, Docker 29.3.1, Redis 7 (Testcontainers)
18+
19+
**Changes from v0.1.25.1:**
20+
- 4 new async event emissions: budget.exhausted, budget.over_limit_entered, budget.debt_incurred, reservation.expired
21+
- `EventEmitterService.emitBalanceEvents()` inspects post-operation balances (no extra Redis calls)
22+
- `ReservationExpiryService.emitExpiredEvent()` adds 1 HGETALL per expired reservation for event payload
23+
- Fixed `PROTOCOL_VERSION` constant: "0.1.24" → "0.1.25"
24+
- No changes to Lua scripts or core hot-path logic
25+
26+
### Single-Threaded Write-Path Latency
27+
28+
| Operation | p50 | p95 | p99 | min | max | mean |
29+
|-------------------|--------|--------|--------|--------|--------|--------|
30+
| Reserve | 6.2ms | 7.3ms | 7.9ms | 4.6ms | 16.3ms | 6.3ms |
31+
| Commit | 4.1ms | 5.2ms | 5.7ms | 3.1ms | 6.3ms | 4.1ms |
32+
| Release | 4.8ms | 6.1ms | 6.5ms | 3.2ms | 6.9ms | 4.9ms |
33+
| Extend | 7.4ms | 9.2ms | 10.2ms | 5.4ms | 19.3ms | 7.5ms |
34+
| Decide | 5.5ms | 6.7ms | 7.0ms | 3.8ms | 20.5ms | 5.6ms |
35+
| Event | 5.2ms | 6.2ms | 6.9ms | 3.3ms | 8.4ms | 5.2ms |
36+
| Reserve + Commit | 14.9ms | 17.5ms | 18.4ms | 10.8ms | 23.7ms | 14.6ms |
37+
| Reserve + Release | 11.4ms | 15.5ms | 16.7ms | 8.7ms | 23.8ms | 11.9ms |
38+
39+
**Write-path analysis:** All write operations are within noise of v0.1.25.1, confirming the new `emitBalanceEvents()` calls add zero measurable overhead. Commit improved slightly (4.1ms vs 5.6ms) — environmental variance from a warmer container. Reserve (6.2ms vs 6.9ms), Event (5.2ms vs 6.0ms), and Release (4.8ms vs 5.8ms) all show minor improvements attributable to container warmth. The new balance event emission runs on the existing async thread pool and only iterates the in-memory balances list (no Redis calls), so it does not touch the request hot path. Extend (7.4ms vs 8.6ms) is consistent with its Lua script complexity. No regressions detected.
40+
41+
### Single-Threaded Read-Path Latency
42+
43+
| Operation | p50 | p95 | p99 | min | max | mean |
44+
|---------------------|--------|--------|--------|--------|--------|--------|
45+
| GET reservation | 2.8ms | 3.6ms | 4.0ms | 2.0ms | 5.3ms | 2.8ms |
46+
| GET balances | 2.9ms | 3.7ms | 3.9ms | 2.1ms | 4.0ms | 2.9ms |
47+
| LIST reservations | 3.3ms | 4.6ms | 5.2ms | 2.3ms | 5.9ms | 3.4ms |
48+
| Decide (pipelined) | 3.5ms | 4.5ms | 5.7ms | 2.8ms | 6.9ms | 3.6ms |
49+
50+
**Read-path analysis:** Read operations improved from v0.1.25.1 (GET reservation 2.8ms vs 4.0ms, GET balances 2.9ms vs 4.1ms, LIST 3.3ms vs 5.0ms, Decide 3.5ms vs 5.6ms). No read-path code was changed, so these improvements are environmental — the v0.1.25.1 benchmark session had higher GC pressure from a different container state. These numbers are now comparable to v0.1.24.3 baselines (GET reservation 2.8ms vs 2.8ms, GET balances 2.9ms vs 2.1ms), confirming the event emission infrastructure adds no sustained overhead to read operations.
51+
52+
### Concurrent Throughput (Reserve+Commit lifecycle)
53+
54+
| Threads | Total Ops | Ops/sec | p50 | p95 | p99 | min | max | Errors |
55+
|---------|-----------|----------|---------|---------|---------|--------|---------|--------|
56+
| 8 | 4,082 | 816.4 | 9.6ms | 11.6ms | 21.0ms | 7.1ms | 24.5ms | 0 |
57+
| 16 | 5,810 | 1,162.0 | 13.7ms | 19.2ms | 22.4ms | 6.1ms | 28.7ms | 0 |
58+
| 32 | 14,363 | 2,872.6 | 10.8ms | 15.1ms | 19.3ms | 6.6ms | 43.1ms | 0 |
59+
60+
**Concurrency analysis:** Throughput at 32 threads is 2,873 ops/s — an 11% improvement over v0.1.25.1's 2,584 ops/s and 13% over v0.1.24.3's 2,534 ops/s. This is environmental variance (warmer container, Docker engine update from 29.2.1 to 29.3.1), not a code improvement. The scaling ratio from 8→32 threads is 3.5x (816 → 2,873 ops/s), consistent with prior versions. p99 at 32 threads (19.3ms) improved from v0.1.25.1's 27.2ms and v0.1.24.3's 22.7ms. Max latency (43.1ms) is consistent with expected Redis Lua serialization tail. Zero errors at all concurrency levels confirms the new balance event emission does not introduce contention — `emitBalanceEvents()` only reads the in-memory balance list on the async thread pool, never competing for Redis connections on the request path. The benchmark happy-path lifecycle (reserve+commit with sufficient budget) does not trigger any of the new events (no exhaustion, no over-limit, no debt), so the emit code path is a no-op during benchmarks. Real-world overhead would be one additional `emit()` call per triggered condition per scope — the same async fire-and-forget path already validated in v0.1.25.1.
61+
62+
---
63+
1264
## v0.1.25.1 — Webhook Event Emission + TTL Retention
1365

1466
**Date:** 2026-04-01

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ cd cycles-protocol-service
5353

5454
# 4. Run
5555
REDIS_HOST=localhost REDIS_PORT=6379 \
56-
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.1.jar
56+
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.3.jar
5757
```
5858

5959
Server starts on **port 7878**. Interactive API docs: http://localhost:7878/swagger-ui.html
@@ -77,7 +77,7 @@ cycles-server-events (port 7980)
7777
Webhook receivers
7878
```
7979

80-
**Event emission:** Runtime operations (reserve denied, commit overage) emit events to the shared Redis dispatch queue. The events delivery service (`cycles-server-events`) picks them up and delivers via HTTP POST with HMAC-SHA256 signing.
80+
**Event emission:** Runtime operations emit events to the shared Redis dispatch queue. The events delivery service (`cycles-server-events`) picks them up and delivers via HTTP POST with HMAC-SHA256 signing.
8181

8282
**Modules** (under `cycles-protocol-service/`):
8383

@@ -117,7 +117,7 @@ mvn clean install
117117
./build-all.sh
118118
```
119119

120-
The fat JAR is produced at `cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.1.jar`.
120+
The fat JAR is produced at `cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.3.jar`.
121121

122122
## Docker Deployment
123123

@@ -136,7 +136,7 @@ Pre-built images are published to GitHub Container Registry on each release:
136136

137137
```
138138
ghcr.io/runcycles/cycles-server:latest
139-
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.1
139+
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.3
140140
```
141141

142142
## Testing
@@ -172,9 +172,13 @@ Integration tests (`*IntegrationTest.java`) use [Testcontainers](https://www.tes
172172

173173
### Webhook Event Emission
174174

175-
The runtime server emits events to the shared Redis dispatch queue when:
176-
- A reservation is **denied** (`reservation.denied`)
177-
- A commit has **actual > estimated** (`reservation.commit_overage`)
175+
The runtime server emits events to the shared Redis dispatch queue for:
176+
- `reservation.denied` — reserve or decide returned DENY
177+
- `reservation.commit_overage` — commit actual exceeded reservation estimate
178+
- `reservation.expired` — reservation TTL expired without commit/release (via background sweeper)
179+
- `budget.exhausted` — remaining budget reached 0 after an operation
180+
- `budget.over_limit_entered` — scope entered over-limit state (debt > overdraft_limit or ALLOW_IF_AVAILABLE cap)
181+
- `budget.debt_incurred` — commit/event created debt via ALLOW_WITH_OVERDRAFT
178182

179183
These events are delivered by `cycles-server-events` to webhook subscribers via HTTP POST with HMAC-SHA256 signing. Event emission is non-blocking — failures are logged but never affect the API response.
180184

cycles-protocol-service/README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ docker run -d -p 6379:6379 redis:7-alpine
7070

7171
# 4. Run
7272
REDIS_HOST=localhost REDIS_PORT=6379 \
73-
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.1.jar
73+
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.3.jar
7474
```
7575

7676
The server starts on **port 7878**. Interactive API docs: http://localhost:7878/swagger-ui.html
@@ -148,13 +148,13 @@ Beyond expires_at_ms: extend blocked (410)
148148

149149
| Policy | Behaviour |
150150
|---|---|
151-
| `REJECT` *(default)* | Commit fails with `409 BUDGET_EXCEEDED` if actual exceeds reserved |
152-
| `ALLOW_IF_AVAILABLE` | Delta is atomically charged from remaining budget; fails with `409 BUDGET_EXCEEDED` if insufficient |
151+
| `REJECT` | Commit fails with `409 BUDGET_EXCEEDED` if actual exceeds reserved |
152+
| `ALLOW_IF_AVAILABLE` *(default)* | Commit always succeeds; delta charged from remaining budget if available, otherwise capped to available remaining and `is_over_limit` set on affected scopes |
153153
| `ALLOW_WITH_OVERDRAFT` | Delta creates debt up to `overdraft_limit`; fails with `409 OVERDRAFT_LIMIT_EXCEEDED` if `debt + delta > overdraft_limit` |
154154

155155
The same three policies apply to `/v1/events` for direct debits.
156156

157-
When `overage_policy` is omitted from the request, the server resolves it from the tenant's `default_commit_overage_policy` (set via the Admin API). If the tenant has no default configured, `REJECT` is used.
157+
When `overage_policy` is omitted from the request, the server resolves it from the tenant's `default_commit_overage_policy` (set via the Admin API). If the tenant has no default configured, `ALLOW_IF_AVAILABLE` is used.
158158

159159
### Debt and Overdraft
160160

@@ -308,7 +308,7 @@ Reserve budget before executing an action. Returns `200 OK`.
308308
| `estimate.amount` | yes || ≥ 0 |
309309
| `ttl_ms` | no | tenant `default_reservation_ttl_ms` or `60000` | 1000–86400000 ms; capped to tenant `max_reservation_ttl_ms` |
310310
| `grace_period_ms` | no | `5000` | 0–60000 ms |
311-
| `overage_policy` | no | tenant `default_commit_overage_policy` or `REJECT` | see Overage Policies |
311+
| `overage_policy` | no | tenant `default_commit_overage_policy` or `ALLOW_IF_AVAILABLE` | see Overage Policies |
312312
| `dry_run` | no | `false` | evaluates without persisting if true |
313313

314314
**Response** `200 OK`
@@ -477,7 +477,7 @@ List reservations for the effective tenant. Optional recovery/debug endpoint. Re
477477

478478
### GET /v1/reservations/{reservation_id}
479479

480-
Fetch a single reservation by ID. Returns `200 OK` for `ACTIVE`, `COMMITTED`, and `RELEASED` statuses. Returns `410 Gone` for `EXPIRED` reservations.
480+
Fetch a single reservation by ID. Returns `200 OK` with the reservation's current status (`ACTIVE`, `COMMITTED`, `RELEASED`, or `EXPIRED`).
481481

482482
**Response** `200 OK`
483483

@@ -553,8 +553,8 @@ Record a direct debit without a prior reservation. Applied atomically across all
553553

554554
| Overage policy | Behaviour |
555555
|---|---|
556-
| `REJECT` *(default)* | Returns `409 BUDGET_EXCEEDED` if `actual > remaining` on any derived scope |
557-
| `ALLOW_IF_AVAILABLE` | Applies atomically only if sufficient remaining exists; otherwise `409 BUDGET_EXCEEDED` |
556+
| `REJECT` | Returns `409 BUDGET_EXCEEDED` if `actual > remaining` on any derived scope |
557+
| `ALLOW_IF_AVAILABLE` *(default)* | Event always succeeds; charges available remaining, caps to available if insufficient, sets `is_over_limit` on affected scopes |
558558
| `ALLOW_WITH_OVERDRAFT` | Creates debt if insufficient; `409 OVERDRAFT_LIMIT_EXCEEDED` if `debt + actual > overdraft_limit` |
559559

560560
`client_time_ms` is advisory only; server time governs all budget and expiry decisions. Debt and over-limit state do **not** block events — only the `overage_policy` logic applies.
@@ -590,10 +590,13 @@ All errors use this envelope:
590590
| `FORBIDDEN` | 403 | Tenant in request does not match API key |
591591
| `NOT_FOUND` | 404 | Reservation, budget, or resource not found |
592592
| `BUDGET_EXCEEDED` | 409 | Insufficient remaining budget |
593+
| `BUDGET_FROZEN` | 409 | Budget is frozen by operator (no mutations allowed) |
594+
| `BUDGET_CLOSED` | 409 | Budget has been permanently closed |
593595
| `RESERVATION_FINALIZED` | 409 | Reservation already committed or released |
594596
| `IDEMPOTENCY_MISMATCH` | 409 | Idempotency key reused with different parameters |
595597
| `OVERDRAFT_LIMIT_EXCEEDED` | 409 | `debt + delta > overdraft_limit`; or scope is over-limit |
596598
| `DEBT_OUTSTANDING` | 409 | Scope has unresolved debt with no overdraft limit; new reservations blocked |
599+
| `MAX_EXTENSIONS_EXCEEDED` | 409 | Tenant's `max_reservation_extensions` limit reached |
597600
| `RESERVATION_EXPIRED` | 410 | Operation attempted after expiry window |
598601
| `INTERNAL_ERROR` | 500 | Unexpected server error |
599602

cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/EventController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package io.runcycles.protocol.api.controller;
22

33
import io.runcycles.protocol.data.repository.RedisReservationRepository;
4+
import io.runcycles.protocol.data.service.EventEmitterService;
45
import io.runcycles.protocol.model.*;
6+
import io.runcycles.protocol.model.event.*;
57
import io.swagger.v3.oas.annotations.*;
68
import io.swagger.v3.oas.annotations.tags.Tag;
79
import jakarta.validation.Valid;
@@ -23,6 +25,9 @@ public class EventController extends BaseController {
2325
@Autowired
2426
private RedisReservationRepository repository;
2527

28+
@Autowired
29+
private EventEmitterService eventEmitter;
30+
2631
@PostMapping
2732
@Operation(operationId = "createEvent", summary = "Record a direct debit event without reservation")
2833
public ResponseEntity<EventCreateResponse> create(
@@ -35,6 +40,10 @@ public ResponseEntity<EventCreateResponse> create(
3540
authorizeTenant(request.getSubject().getTenant());
3641
String tenant = extractAuthTenantId();
3742
EventCreateResponse response = repository.createEvent(request, tenant);
43+
try {
44+
Actor actor = Actor.builder().type(ActorType.API_KEY).build();
45+
eventEmitter.emitBalanceEvents(response.getBalances(), tenant, actor, null, null);
46+
} catch (Exception e) { /* non-blocking */ }
3847
return ResponseEntity.status(HttpStatus.CREATED).body(response);
3948
}
4049
}

cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/ReservationController.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ public ResponseEntity<ReservationCreateResponse> create(
4444
String tenant = extractAuthTenantId();
4545
ReservationCreateResponse response = repository.createReservation(request, tenant);
4646
try {
47+
Actor actor = Actor.builder().type(ActorType.API_KEY).build();
4748
if (response.getDecision() == Enums.DecisionEnum.DENY) {
4849
eventEmitter.emit(EventType.RESERVATION_DENIED, tenant, response.getScopePath(),
49-
Actor.builder().type(ActorType.API_KEY).build(),
50+
actor,
5051
EventDataReservationDenied.builder()
5152
.scope(response.getScopePath())
5253
.reasonCode(response.getReasonCode())
@@ -55,6 +56,8 @@ public ResponseEntity<ReservationCreateResponse> create(
5556
.build(),
5657
null, null);
5758
}
59+
// Emit budget state events from post-operation balances
60+
eventEmitter.emitBalanceEvents(response.getBalances(), tenant, actor, null, null);
5861
} catch (Exception e) { /* non-blocking */ }
5962
return ResponseEntity.ok(response);
6063
}
@@ -82,18 +85,21 @@ public ResponseEntity<CommitResponse> commit(
8285
authorizeTenant(tenant);
8386
CommitResponse response = repository.commitReservation(reservationId, request);
8487
try {
88+
Actor actor = Actor.builder().type(ActorType.API_KEY).build();
8589
// Emit commit_overage only when actual charge exceeds the original reservation estimate
8690
if (response.getEstimateAmount() != null && response.getCharged() != null
8791
&& response.getCharged().getAmount() != null
8892
&& response.getCharged().getAmount() > response.getEstimateAmount()) {
8993
eventEmitter.emit(EventType.RESERVATION_COMMIT_OVERAGE, tenant, null,
90-
Actor.builder().type(ActorType.API_KEY).build(),
94+
actor,
9195
EventDataCommitOverage.builder()
9296
.reservationId(reservationId)
9397
.actualAmount(response.getCharged().getAmount())
9498
.build(),
9599
null, null);
96100
}
101+
// Emit budget state events from post-operation balances
102+
eventEmitter.emitBalanceEvents(response.getBalances(), tenant, actor, null, null);
97103
} catch (Exception e) { /* non-blocking */ }
98104
return ResponseEntity.ok(response);
99105
}

0 commit comments

Comments
 (0)