@xmbl/state-machine 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmbl/state-machine",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -0,0 +1,12 @@
1
+ // OUTCOME TEST: applied_tx_count after the convergence primitive. The metric the whole fleet reads.
2
+ const { StateMachine } = await import("../index.js");
3
+ const sm = new StateMachine({ dbPath: null });
4
+ sm._dbOpen = false;
5
+ const anchors = Array.from({ length: 3941 }, (_, i) => ({ event: 'task.created', hash: 'h' + i, ts: 1000 + i }));
6
+ const r = await sm.rebuildFromCanonical(anchors);
7
+ const s = sm.getStatistics();
8
+ console.log(`ROOT ${r.state_root.slice(0,10)} applied(returned) ${r.applied} applied_tx_count(published) ${s.totalTransactions}`);
9
+ // idempotence: the same set twice must not double the count
10
+ const r2 = await sm.rebuildFromCanonical(anchors);
11
+ console.log(`SECOND PASS same set: root ${r2.state_root.slice(0,10)} applied_tx_count ${sm.getStatistics().totalTransactions}`);
12
+ process.exit(0);
@@ -21,6 +21,7 @@ export class StateMachine extends EventEmitter {
21
21
  this.shards = [];
22
22
  this.totalShards = options.totalShards || 4;
23
23
  this.diffs = [];
24
+ this._diffIndex = new Map();
24
25
  this.transactionLog = [];
25
26
 
26
27
  // Initialize shards
@@ -76,7 +77,7 @@ export class StateMachine extends EventEmitter {
76
77
  const diffData = JSON.parse(value.toString());
77
78
  const diff = new StateDiff(diffData.txId, diffData.changes);
78
79
  diff.timestamp = diffData.timestamp;
79
- this.diffs.push(diff);
80
+ this._recordDiff(diff);
80
81
  loaded.push(diff);
81
82
  }
82
83
  loaded.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || String(a.txId).localeCompare(String(b.txId)));
@@ -142,6 +143,23 @@ export class StateMachine extends EventEmitter {
142
143
  // Every transaction type IS a state change, so each maps to its natural key space. Keys are namespaced by
143
144
  // type so two kinds can never collide, and values carry only consensus-derived fields — nothing node-local,
144
145
  // because the state root is a cross-node commitment.
146
+ // applied_tx_count COUNTED APPLY CALLS, NOT TRANSACTIONS. Every apply site appended to `this.diffs`
147
+ // unconditionally while persisting to `diff:<txId>`, a key that OVERWRITES. So a second apply_backfill over
148
+ // an unchanged block set left the verkle root identical (the root is a function of the final key SET) and
149
+ // still added one array entry per block: MEASURED on node xmb0844bbed..., 82910 -> 104446 over the same
150
+ // 21536 blocks, then 88486 after a restart reloaded the deduped rows from disk. A monitoring number that
151
+ // moves on a repeated no-op cannot tell work from a replay, and /api/v1/xmbl/status publishes this one.
152
+ // Upsert by txId so the in-memory set matches the durable one it was always meant to mirror.
153
+ //
154
+ // REPLACE, don't skip: re-applying a txId with different changes is a legitimate later state for that key
155
+ // and the disk already resolves it that way. Skipping would leave the array disagreeing with the tree.
156
+ _recordDiff(diff) {
157
+ const at = this._diffIndex.get(diff.txId);
158
+ if (at === undefined) { this._diffIndex.set(diff.txId, this.diffs.length); this.diffs.push(diff); return true; }
159
+ this.diffs[at] = diff;
160
+ return false;
161
+ }
162
+
145
163
  _stateChangesFor(block) {
146
164
  const tx = block?.tx;
147
165
  if (!tx || typeof tx !== 'object') return null;
@@ -182,7 +200,7 @@ export class StateMachine extends EventEmitter {
182
200
  if (!changes || !Object.keys(changes).length) return;
183
201
  try {
184
202
  const diff = new StateDiff(block.id, changes);
185
- this.diffs.push(diff);
203
+ this._recordDiff(diff);
186
204
  for (const [key, value] of Object.entries(changes)) {
187
205
  await this.stateTree.insert(key, value);
188
206
  }
@@ -231,7 +249,7 @@ export class StateMachine extends EventEmitter {
231
249
  if (!changes || !Object.keys(changes).length) { out.skipped++; continue; }
232
250
  try {
233
251
  const diff = new StateDiff(block.id, changes);
234
- this.diffs.push(diff);
252
+ this._recordDiff(diff);
235
253
  for (const [key, val] of Object.entries(changes)) await this.stateTree.insert(key, val);
236
254
  if (this._dbOpen !== false) {
237
255
  try { await this.db.put(`diff:${block.id}`, diff.serialize()); } catch { /* in-memory fallback */ }
@@ -254,10 +272,23 @@ export class StateMachine extends EventEmitter {
254
272
  const out = { requested: list.length, applied: 0, skipped: 0, state_root: null, started: true };
255
273
  await this.stateTree.clear();
256
274
  this.diffs = [];
275
+ this._diffIndex.clear();
276
+ // ⛔ RECORD A DIFF FOR EVERY ANCHOR APPLIED, or applied_tx_count IS ZERO BY CONSTRUCTION. This loop wrote
277
+ // straight into the tree and never touched `this.diffs`, which it had just emptied — and
278
+ // getStatistics().totalTransactions is transactionLog.length + diffs.length. So the moment a node runs the
279
+ // convergence primitive, the number the whole fleet reads as "is this node applying anything" resets to 0
280
+ // and STAYS 0 no matter how many anchors it applied. MEASURED 2026-09-15: this node held a correct root
281
+ // over 3,941 applied anchors and published applied_tx_count 0; across the fleet 42 of 46 reporting nodes
282
+ // read 0, and a design ruling was written on the premise that 41 of them had "applied nothing". They had.
283
+ // The diff is not bookkeeping — it is the same StateDiff the block path records, keyed by the anchor's own
284
+ // content, so a rebuild and a live apply of the same anchor upsert to ONE row rather than two.
257
285
  for (const a of list) {
258
286
  if (!a || !a.event || !a.hash) { out.skipped++; continue; }
259
287
  try {
260
- await this.stateTree.insert(`anchor:${a.event}:${a.hash}`, { ts: a.ts ?? null });
288
+ const key = `anchor:${a.event}:${a.hash}`;
289
+ const value = { ts: a.ts ?? null };
290
+ await this.stateTree.insert(key, value);
291
+ this._recordDiff(new StateDiff(key, { [key]: value }));
261
292
  out.applied++;
262
293
  } catch { out.skipped++; }
263
294
  }