Skip to content

Commit 3c82e4e

Browse files
authored
Merge pull request #78 from runcycles/release/v0.1.25.5
fix: transition-based event emission to prevent duplicate budget events (v0.1.25.5)
2 parents 844b290 + 7c25d29 commit 3c82e4e

16 files changed

Lines changed: 264 additions & 33 deletions

File tree

AUDIT.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Cycles Protocol v0.1.25 — Server Implementation Audit
22

3-
**Date:** 2026-04-07 (v0.1.25.4 event data completeness), 2026-04-01 (v0.1.25 event emission + TTL), 2026-03-24 (Round 6: spec compliance audit), 2026-03-24 (v0.1.24 update), 2026-03-23 (updated), 2026-03-15 (initial)
3+
**Date:** 2026-04-08 (v0.1.25.5 duplicate event fix), 2026-04-07 (v0.1.25.4 event data completeness), 2026-04-01 (v0.1.25 event emission + TTL), 2026-03-24 (Round 6: spec compliance audit), 2026-03-24 (v0.1.24 update), 2026-03-23 (updated), 2026-03-15 (initial)
44
**Spec:** `cycles-protocol-v0.yaml` (OpenAPI 3.1.0, v0.1.25) + `complete-budget-governance-v0.1.25.yaml` (events/webhooks)
55
**Server:** Spring Boot 3.5.11 / Java 21 / Redis (Lua scripts)
66

@@ -38,6 +38,19 @@
3838

3939
**Remaining event data gaps:** None. All EventData fields now fully populated for runtime-emitted events.
4040

41+
### 2026-04-08 — v0.1.25.5: Fix duplicate budget state events (cycles-server-events#15)
42+
43+
**Bug:** `budget.exhausted`, `budget.over_limit_entered`, and `budget.debt_incurred` events fired on every operation where the post-state matched the condition, not only on state *transitions*. For example, a reserve that depleted a budget emitted `budget.exhausted`, then the subsequent commit (with remaining still at 0) emitted it again.
44+
45+
**Root cause:** `EventEmitterService.emitBalanceEvents()` checked post-mutation state only (e.g., `remaining == 0`). No transition detection.
46+
47+
**Fix:** Lua scripts (reserve, commit, event) now include `pre_remaining` and `pre_is_over_limit` per scope in balance snapshots. Java emits only on transitions:
48+
- `budget.exhausted`: `pre_remaining > 0 && remaining == 0`
49+
- `budget.over_limit_entered`: `!pre_is_over_limit && is_over_limit`
50+
- `budget.debt_incurred`: `scopeDebtIncurred[scope] > 0` (already tracked)
51+
52+
**Performance:** No extra Redis calls. reserve.lua caches pre-state from existing validation HMGET. commit.lua caches from existing overage-path reads (ALLOW_IF_AVAILABLE/ALLOW_WITH_OVERDRAFT); delta <= 0 paths skip (remaining can only increase). event.lua folds `is_over_limit` into existing HMGET.
53+
4154
---
4255

4356
### 2026-04-03 — v0.1.25.3: Extended runtime event emission + PROTOCOL_VERSION fix

BENCHMARKS.md

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

1010
---
1111

12+
## v0.1.25.5 — Transition-Based Event Emission (Duplicate Event Fix)
13+
14+
**Date:** 2026-04-08
15+
**Branch:** `release/v0.1.25.5`
16+
**Base commit:** `9b2f4e1`
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.4:**
20+
- Lua scripts (reserve, commit, event) include pre-mutation `pre_remaining` and `pre_is_over_limit` per scope in balance snapshots
21+
- Java emits budget state events only on state transitions (not every matching post-state)
22+
- commit.lua ALLOW_IF_AVAILABLE path: HGET → HMGET (remaining + is_over_limit in one call)
23+
- commit.lua ALLOW_WITH_OVERDRAFT path: added is_over_limit to existing HMGET (4 → 5 fields)
24+
- event.lua: folded is_over_limit into existing HMGET (5 → 6 fields, removed separate HGET)
25+
- No extra Redis calls on any path
26+
27+
### Single-Threaded Write-Path Latency
28+
29+
| Operation | p50 | p95 | p99 | min | max | mean |
30+
|-------------------|--------|--------|--------|--------|--------|--------|
31+
| Reserve | 7.2ms | 8.3ms | 8.7ms | 5.8ms | 16.1ms | 7.2ms |
32+
| Commit | 5.8ms | 6.6ms | 7.4ms | 4.6ms | 14.2ms | 5.8ms |
33+
| Release | 6.0ms | 6.9ms | 7.2ms | 4.4ms | 14.2ms | 6.0ms |
34+
| Extend | 9.0ms | 10.3ms | 12.9ms | 7.4ms | 19.3ms | 9.0ms |
35+
| Decide | 6.3ms | 7.5ms | 8.4ms | 4.4ms | 10.5ms | 6.3ms |
36+
| Event | 6.2ms | 7.3ms | 7.7ms | 4.4ms | 8.2ms | 6.2ms |
37+
| Reserve + Commit | 16.7ms | 18.9ms | 20.6ms | 13.0ms | 28.5ms | 16.7ms |
38+
| Reserve + Release | 14.2ms | 17.2ms | 21.3ms | 11.5ms | 25.3ms | 14.2ms |
39+
40+
**Write-path analysis:** All operations consistent with v0.1.25.4. Reserve (7.2ms vs 5.7ms), Commit (5.8ms vs 4.7ms), Release (6.0ms vs 4.8ms), Extend (9.0ms vs 7.6ms), Decide (6.3ms vs 5.5ms), Event (6.2ms vs 5.1ms) — all within normal container/JVM warmth variance across benchmark sessions. The pre-state caching in Lua adds zero extra Redis calls: reserve.lua caches from its existing validation HMGET, commit.lua from existing overage-path reads, event.lua folds is_over_limit into its existing HMGET. No regressions.
41+
42+
### Single-Threaded Read-Path Latency
43+
44+
| Operation | p50 | p95 | p99 | min | max | mean |
45+
|---------------------|--------|--------|--------|--------|--------|--------|
46+
| GET reservation | 3.5ms | 4.3ms | 4.8ms | 2.0ms | 5.2ms | 3.5ms |
47+
| GET balances | 3.6ms | 4.5ms | 4.8ms | 2.1ms | 4.9ms | 3.6ms |
48+
| LIST reservations | 3.9ms | 4.6ms | 4.8ms | 2.4ms | 4.9ms | 3.8ms |
49+
| Decide (pipelined) | 4.2ms | 5.0ms | 5.5ms | 2.9ms | 6.2ms | 4.2ms |
50+
51+
**Read-path analysis:** No read-path code was changed. All operations consistent with v0.1.25.4 (GET reservation 3.5ms vs 3.8ms, GET balances 3.6ms vs 4.0ms). No regressions.
52+
53+
### Concurrent Throughput (Reserve+Commit lifecycle)
54+
55+
| Threads | Total Ops | Ops/sec | p50 | p95 | p99 | min | max | Errors |
56+
|---------|-----------|----------|---------|---------|---------|--------|---------|--------|
57+
| 8 | 3,764 | 752.8 | 10.3ms | 12.8ms | 23.2ms | 7.4ms | 32.0ms | 0 |
58+
| 16 | 5,316 | 1,063.2 | 14.7ms | 21.4ms | 26.0ms | 6.8ms | 51.5ms | 0 |
59+
| 32 | 12,599 | 2,519.8 | 11.5ms | 21.2ms | 34.5ms | 6.7ms | 67.6ms | 0 |
60+
61+
**Concurrency analysis:** Throughput at 32 threads is 2,520 ops/s — within 5% of v0.1.25.4's 2,655 ops/s. The scaling ratio from 8→32 threads is 3.3x (753 → 2,520), consistent with prior versions (v0.1.25.4: 3.4x, v0.1.25.3: 3.5x). p99 at 32 threads (34.5ms) is comparable to v0.1.25.4's 29.8ms. Zero errors at all concurrency levels. The transition fix adds no measurable overhead — pre-state caching piggybacks on existing Lua reads with zero extra Redis calls. Earlier runs on this day showed degraded numbers (~725 ops/s) due to TuneupUI background processes consuming CPU; after termination, normal throughput restored.
62+
63+
---
64+
1265
## v0.1.25.4 — Event Data Payload Completeness
1366

1467
**Date:** 2026-04-07

README.md

Lines changed: 3 additions & 3 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.4.jar
56+
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.5.jar
5757
```
5858

5959
Server starts on **port 7878**. Interactive API docs: http://localhost:7878/swagger-ui.html
@@ -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.4.jar`.
120+
The fat JAR is produced at `cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.5.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.4
139+
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.5
140140
```
141141

142142
## Testing

cycles-protocol-service/README.md

Lines changed: 1 addition & 1 deletion
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.4.jar
73+
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.5.jar
7474
```
7575

7676
The server starts on **port 7878**. Interactive API docs: http://localhost:7878/swagger-ui.html

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ public ResponseEntity<EventCreateResponse> create(
4747
String policy = request.getOveragePolicy() != null
4848
? request.getOveragePolicy().name() : "ALLOW_IF_AVAILABLE";
4949
eventEmitter.emitBalanceEvents(response.getBalances(), tenant, actor,
50-
null, policy, response.getScopeDebtIncurred(), null, null);
50+
null, policy, response.getScopeDebtIncurred(),
51+
response.getPreRemaining(), response.getPreIsOverLimit(),
52+
null, null);
5153
} catch (Exception e) { /* non-blocking */ }
5254
return ResponseEntity.status(HttpStatus.CREATED).body(response);
5355
}

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,11 @@ public ResponseEntity<ReservationCreateResponse> create(
8181
.build(),
8282
null, null);
8383
}
84-
// Emit budget state events from post-operation balances
85-
eventEmitter.emitBalanceEvents(response.getBalances(), tenant, actor, null, null);
84+
// Emit budget state events from post-operation balances (transition-based)
85+
eventEmitter.emitBalanceEvents(response.getBalances(), tenant, actor,
86+
null, null, null,
87+
response.getPreRemaining(), response.getPreIsOverLimit(),
88+
null, null);
8689
} catch (Exception e) { /* non-blocking */ }
8790
return ResponseEntity.ok(response);
8891
}
@@ -133,10 +136,12 @@ public ResponseEntity<CommitResponse> commit(
133136
.build(),
134137
null, null);
135138
}
136-
// Emit budget state events from post-operation balances
139+
// Emit budget state events from post-operation balances (transition-based)
137140
eventEmitter.emitBalanceEvents(response.getBalances(), tenant, actor,
138141
reservationId, response.getOveragePolicy(),
139-
response.getScopeDebtIncurred(), null, null);
142+
response.getScopeDebtIncurred(),
143+
response.getPreRemaining(), response.getPreIsOverLimit(),
144+
null, null);
140145
} catch (Exception e) { /* non-blocking */ }
141146
return ResponseEntity.ok(response);
142147
}

cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/RedisReservationRepository.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ public ReservationCreateResponse createReservation(ReservationCreateRequest requ
130130
.expiresAtMs(((Number) response.get("expires_at")).longValue())
131131
.caps(caps)
132132
.balances(balances)
133+
.preRemaining(parsePreRemaining(response))
134+
.preIsOverLimit(parsePreIsOverLimit(response))
133135
.build();
134136
} catch (CyclesProtocolException e) {
135137
throw e;
@@ -344,6 +346,8 @@ public CommitResponse commitReservation(String reservationId, CommitRequest requ
344346
.overagePolicy(overagePolicy)
345347
.debtIncurred(luaDebt != null ? luaDebt.longValue() : null)
346348
.scopeDebtIncurred(scopeDebtIncurred)
349+
.preRemaining(parsePreRemaining(response))
350+
.preIsOverLimit(parsePreIsOverLimit(response))
347351
.build();
348352
} catch (CyclesProtocolException e){
349353
LOG.error("Failed logic to commit reservation", e);
@@ -844,6 +848,8 @@ public EventCreateResponse createEvent(EventCreateRequest request, String tenant
844848
.charged(charged)
845849
.balances(balances)
846850
.scopeDebtIncurred(scopeDebtIncurred)
851+
.preRemaining(parsePreRemaining(response))
852+
.preIsOverLimit(parsePreIsOverLimit(response))
847853
.build();
848854
} catch (CyclesProtocolException e) {
849855
throw e;
@@ -1033,6 +1039,44 @@ private List<Balance> fetchBalancesForScopes(Jedis jedis, List<String> scopes, E
10331039
* Extract per-scope debt_incurred from Lua balance entries.
10341040
* Returns a map of scope → debt incurred during this operation.
10351041
*/
1042+
/**
1043+
* Extract per-scope pre-mutation remaining from Lua balance entries.
1044+
* Used for transition detection: emit budget.exhausted only when pre_remaining > 0 && remaining == 0.
1045+
*/
1046+
private Map<String, Long> parsePreRemaining(Map<String, Object> response) {
1047+
Object balancesObj = response.get("balances");
1048+
if (balancesObj == null || !(balancesObj instanceof List)) {
1049+
return Collections.emptyMap();
1050+
}
1051+
List<Map<String, Object>> luaBalances = (List<Map<String, Object>>) balancesObj;
1052+
Map<String, Long> result = new java.util.HashMap<>();
1053+
for (Map<String, Object> lb : luaBalances) {
1054+
String scope = (String) lb.get("scope");
1055+
long preRemaining = ((Number) lb.getOrDefault("pre_remaining", 0)).longValue();
1056+
result.put(scope, preRemaining);
1057+
}
1058+
return result;
1059+
}
1060+
1061+
/**
1062+
* Extract per-scope pre-mutation is_over_limit from Lua balance entries.
1063+
* Used for transition detection: emit budget.over_limit_entered only when pre=false && post=true.
1064+
*/
1065+
private Map<String, Boolean> parsePreIsOverLimit(Map<String, Object> response) {
1066+
Object balancesObj = response.get("balances");
1067+
if (balancesObj == null || !(balancesObj instanceof List)) {
1068+
return Collections.emptyMap();
1069+
}
1070+
List<Map<String, Object>> luaBalances = (List<Map<String, Object>>) balancesObj;
1071+
Map<String, Boolean> result = new java.util.HashMap<>();
1072+
for (Map<String, Object> lb : luaBalances) {
1073+
String scope = (String) lb.get("scope");
1074+
boolean preOverLimit = Boolean.TRUE.equals(lb.get("pre_is_over_limit"));
1075+
result.put(scope, preOverLimit);
1076+
}
1077+
return result;
1078+
}
1079+
10361080
private Map<String, Long> parseScopeDebtIncurred(Map<String, Object> response) {
10371081
Object balancesObj = response.get("balances");
10381082
if (balancesObj == null || !(balancesObj instanceof List)) {

cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/service/EventEmitterService.java

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,32 +86,44 @@ public void emit(EventType type, String tenantId, String scope, Actor actor,
8686
*/
8787
public void emitBalanceEvents(List<Balance> balances, String tenantId, Actor actor,
8888
String correlationId, String requestId) {
89-
emitBalanceEvents(balances, tenantId, actor, null, null, null, correlationId, requestId);
89+
emitBalanceEvents(balances, tenantId, actor, null, null, null,
90+
null, null, correlationId, requestId);
9091
}
9192

9293
/**
9394
* Overload accepting reservation context for richer event data on debt_incurred events.
9495
*/
9596
public void emitBalanceEvents(List<Balance> balances, String tenantId, Actor actor,
9697
String reservationId, String overagePolicy,
98+
Map<String, Long> scopeDebtIncurred,
9799
String correlationId, String requestId) {
98-
emitBalanceEvents(balances, tenantId, actor, reservationId, overagePolicy, null, correlationId, requestId);
100+
emitBalanceEvents(balances, tenantId, actor, reservationId, overagePolicy, scopeDebtIncurred,
101+
null, null, correlationId, requestId);
99102
}
100103

101104
/**
102-
* Full overload with per-scope debt incurred map for complete EventDataBudgetDebtIncurred.
105+
* Full overload with pre-mutation state maps for transition-based event emission.
106+
* Only emits budget state events on state transitions (not on every operation where
107+
* the post-state matches). Fixes duplicate event firing (cycles-server-events#15).
103108
*/
104109
public void emitBalanceEvents(List<Balance> balances, String tenantId, Actor actor,
105110
String reservationId, String overagePolicy,
106111
Map<String, Long> scopeDebtIncurred,
112+
Map<String, Long> preRemaining,
113+
Map<String, Boolean> preIsOverLimit,
107114
String correlationId, String requestId) {
108115
Map<String, Long> debtMap = scopeDebtIncurred != null ? scopeDebtIncurred : Collections.emptyMap();
116+
Map<String, Long> preRem = preRemaining != null ? preRemaining : Collections.emptyMap();
117+
Map<String, Boolean> preOvl = preIsOverLimit != null ? preIsOverLimit : Collections.emptyMap();
109118
if (balances == null || balances.isEmpty()) return;
110119
for (Balance b : balances) {
120+
String scopePath = b.getScopePath();
111121
String unit = b.getRemaining() != null ? b.getRemaining().getUnit().name() : null;
112-
// budget.exhausted — remaining.amount == 0
122+
long preRemainingVal = preRem.getOrDefault(scopePath, Long.MAX_VALUE);
123+
boolean preOverLimitVal = preOvl.getOrDefault(scopePath, false);
124+
// budget.exhausted — transition: pre_remaining > 0 && remaining == 0
113125
if (b.getRemaining() != null && b.getRemaining().getAmount() != null
114-
&& b.getRemaining().getAmount() == 0L) {
126+
&& b.getRemaining().getAmount() == 0L && preRemainingVal > 0L) {
115127
// Spec: use EventDataBudgetThreshold with threshold=1.0, direction=rising
116128
Double utilization = null;
117129
Long allocated = b.getAllocated() != null ? b.getAllocated().getAmount() : null;
@@ -136,8 +148,8 @@ public void emitBalanceEvents(List<Balance> balances, String tenantId, Actor act
136148
.build(),
137149
correlationId, requestId);
138150
}
139-
// budget.over_limit_entered — is_over_limit flipped to true
140-
if (Boolean.TRUE.equals(b.getIsOverLimit())) {
151+
// budget.over_limit_entered — transition: pre=false && post=true
152+
if (Boolean.TRUE.equals(b.getIsOverLimit()) && !preOverLimitVal) {
141153
Long debt = b.getDebt() != null ? b.getDebt().getAmount() : null;
142154
Long odLimit = b.getOverdraftLimit() != null ? b.getOverdraftLimit().getAmount() : null;
143155
Double debtUtilization = (debt != null && odLimit != null && odLimit > 0)
@@ -154,11 +166,11 @@ public void emitBalanceEvents(List<Balance> balances, String tenantId, Actor act
154166
.build(),
155167
correlationId, requestId);
156168
}
157-
// budget.debt_incurred — debt > 0
158-
if (b.getDebt() != null && b.getDebt().getAmount() != null
159-
&& b.getDebt().getAmount() > 0L) {
169+
// budget.debt_incurred — only when new debt was created in this operation
170+
Long perScopeDebt = debtMap.get(scopePath);
171+
if (perScopeDebt != null && perScopeDebt > 0L
172+
&& b.getDebt() != null && b.getDebt().getAmount() != null) {
160173
Long odLimit = b.getOverdraftLimit() != null ? b.getOverdraftLimit().getAmount() : null;
161-
Long perScopeDebt = debtMap.get(b.getScopePath());
162174
emit(EventType.BUDGET_DEBT_INCURRED, tenantId, b.getScopePath(),
163175
actor,
164176
EventDataBudgetDebtIncurred.builder()

0 commit comments

Comments
 (0)