Skip to content

feat(core): fix #268 #269 #270 #271 bounded cache, synthetic monitor,… - #297

Merged
BigDella merged 1 commit into
Parcel-Protocol:mainfrom
Oge8:feat/268-271-cache-synthetic-fuzz-concurrency
Oct 18, 2026
Merged

BigDella merged 1 commit into
Parcel-Protocol:mainfrom
Oge8:feat/268-271-cache-synthetic-fuzz-concurrency

Conversation

@Oge8

@Oge8 Oge8 commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Closes #268
Closes #269
Closes #270
Closes #271

PR Description

This PR resolves four core hardening issues: #270, #269, #268 and #271.
Each issue is a focused change under core/ with its own tests and docs. No
feature slice, route or UI changed. Every change to existing code either adds
something new or only affects inputs that used to throw, so existing contracts
are preserved.

#270: Bounded cache with explicit invalidation and stale detection

Problem. core/cache had a stale detector (#236) but no cache for it to
protect. Nothing bounded memory, nothing gave callers a way to invalidate
entries, and nothing stopped an out-of-order Horizon response from overwriting
newer state.

How it works now. createBoundedCache<V>() in core/cache/bounded-cache.ts:

  • Bounded. It is an LRU cache capped at maxEntries. A Map's insertion
    order is the recency order, so get, set, invalidate and getOrLoad are
    all O(1).
  • Detects stale data.
    • An entry older than its TTL returns cache_expired. It is dropped when
      read, so no timers are needed.
    • A write with an older ledgerSequence than the live entry returns
      cache_stale_write, so a projection can never move back to an older ledger.
  • Explicit invalidation. invalidate(key) drops one key and
    invalidateWhere(predicate) drops a scope. Both also cancel any load in
    flight for those keys.
  • Retry and failure.
    • getOrLoad makes concurrent misses for a key share one load.
    • Failed loads are never cached, so the next call retries.
    • A loader that throws frees its in-flight slot.
    • A load invalidated while in flight answers its caller but does not store
      its value.
  • Authorization. clear() flushes everything and requires the
    admin:recovery permission. Without it, clear() returns
    cache_unauthorized. Both outcomes emit cache.flushed telemetry.
  • Detector integration. snapshot() returns the existing CacheEntry
    shape, so StaleProjectionDetector.inspect reports ledger drift, schema skew
    and orphans for this cache with no changes.

Why it matters. Horizon reads can be cached safely: memory stays bounded,
duplicate requests are coalesced, and stale or rolled-back values are never
served.

#269: Synthetic monitoring for the critical end-to-end journey

Problem. tests/e2e/critical-journey.spec.ts tests the operation-browser
journey only against mocked Horizon. An upstream break (wrong network, changed
payload, broken pagination) was invisible until users reported it.

How it works now. core/health/synthetic.ts sends the journey's exact
requests to live Horizon (order=desc, limit=20, cursor = last
paging_token):

  1. horizon.root (GET /): checks that network_passphrase matches the
    network and that ingestion lag (core_latest_ledger − history_latest_ledger)
    is at most 5 ledgers.
  2. operations.discover (GET /operations?order=desc&limit=1): picks a
    currently active G… account. No fixture account is needed, so testnet
    resets cannot break the probe.
  3. account.operations: page one must be non-empty, with numeric, strictly
    descending paging tokens.
  4. account.operations.older: follows the cursor when page one is full,
    just as the "Load more" button does. Every token must be below the cursor.
    This matches Horizon's own id < cursor ORDER BY id desc query.
  • Retry. Transient failures (429, 5xx, transport errors, timeouts) are
    retried up to 3 attempts with exponential backoff. A Retry-After header is
    honoured (RFC 9110: seconds or an HTTP-date), and no wait exceeds 10 s.
  • No retry for contract failures. network_mismatch, malformed_payload,
    http_status, no_activity and pagination_broken fail at once, because
    retrying cannot fix them.
  • Status.
    • unhealthy: a step failed.
    • degraded: the run passed but needed a retry, took longer than 5 s, or
      ingestion lagged.
    • healthy: everything else.
  • Authorization. Running the probe requires the admin:health permission
    (the service and maintainer roles). Viewers and unknown roles are refused
    before any request is made.
  • Runner. npm run synthetic:monitor -- --network testnet|mainnet [--json].
    • It needs Node 24 and no npm install.
    • Exit codes: 0 healthy or degraded, 1 unhealthy, 2 refused or crashed.
    • .github/workflows/synthetic-monitor.yml runs it every 30 minutes for both
      networks.

Why it matters. Upstream regressions in the most important user journey
are detected on a schedule, with a correlation id for diagnosis, before users
notice them.

#268: Fuzz coverage for malformed payloads and boundary numeric values

How it works now. core/__tests__/fuzz.test.ts is a seeded fuzz suite. It
uses a mulberry32 PRNG, runs 1,000 inputs per property, can be reproduced with
FUZZ_SEED, and adds no new dependency. It covers:

  • Stellar amounts at the int64 bounds (922337203685.4775807 /
    -922337203685.4775808), including the one-stroop overflows, and exact
    round-trips of random int64 values.
  • Malformed JSON-RPC envelopes, including cycles, BigInt, symbols and
    prototype keys.
  • Idempotency payloads and keys.
  • Malformed authorization roles.

The suite found three real defects, each fixed with a minimal change:

Defect Fix
hasPermission threw a TypeError for any unknown role. A mistyped UTIX_USER_ROLE crashed every permission check, and toString/__proto__ resolved to functions. Object.hasOwn guard; unknown roles are denied
canonicalize threw on BigInt and overflowed the stack on cyclic payloads, so IdempotencyStore.begin threw instead of returning a Result. BigInt is written as 10n and a cycle as #cycle; neither is valid JSON, so neither collides with a real value
parseJsonRpcResponse accepted NaN and fractional error codes. Codes must be integers, as JSON-RPC 2.0 §5.1 requires

Why it matters. Authorization and replay protection no longer crash on
unexpected input. Every input that previously worked keeps its exact behaviour,
so persisted idempotency digests remain valid.

#271: Concurrency tests for duplicate submissions and stale versions

Problem.

  • Duplicate submissions were only tested one after another.
  • NotificationPreferences.version was incremented but never checked. A writer
    acting on a stale read silently overwrote a newer write; for example, a toggle
    computed from a stale muted value re-muted a user who had just unmuted.

How it works now.

  • Duplicate submissions. idempotency.concurrency.test.ts holds work open
    on gates and asserts the exact interleaving, with no sleeps. It shows that:
    • concurrent duplicates run once;
    • retries replay the first result;
    • two tabs sharing storage are deduplicated;
    • recorded or thrown failures are replayed, never re-run;
    • a stuck claim re-runs only after an audited abandon;
    • payload or operation collisions are refused;
    • a late duplicate is rejected as expired.
  • Stale versions. updatePreferences(recipient, patch, { expectedVersion })
    is now an optional compare-and-set. On a version mismatch it throws
    StalePreferencesVersionError (with recipient, expectedVersion and
    actualVersion) and leaves storage untouched. The caller re-reads and
    retries.
  • Backwards compatible. Omitting the option keeps the previous
    last-write-wins behaviour, so no existing caller changes.

Why it matters. Double clicks, retries and multi-tab use cannot repeat side
effects or silently lose a newer preference change.

Verification of Stellar specifics

Every Stellar detail was checked against the official Stellar docs and Horizon's
source (stellar/stellar-horizon):

  • amount precision and bounds;
  • network passphrases;
  • testnet resets;
  • the default rate limit (3,600 requests/hour) and the 429 status;
  • the GET / field names;
  • the desc cursor semantics;
  • that paging_token is the decimal operation ID;
  • that source_account is always the G… account.

The probe ran live and reported HEALTHY on both testnet and mainnet.

Changed

Testing

Run on Node 24.11.0:

# Tests for #268, #269, #270, #271 and the modules they touch (13 files, 156 tests passed)
npx vitest run core/cache core/health core/__tests__/fuzz core/idempotency core/notifications core/rpc core/auth

# Full suite (3,109 passed; the 22 failures are 21 that already fail on main, plus a load-flaky EffectsTimelinePanel test)
npx vitest run
npx vitest run features/effects-timeline/__tests__/EffectsTimelinePanel.test.tsx   # 13/13 passed, 3 runs in a row

# Lint and typecheck (no errors in changed files)
npx eslint core/cache core/health/synthetic.ts core/health/__tests__/synthetic.test.ts core/__tests__/fuzz.test.ts core/idempotency core/notifications core/rpc/client.ts core/auth/types.ts scripts/synthetic-monitor.mjs
npx tsc --noEmit -p .

# Repository gates (all pass; verify:features fails on the same 5 feature slices as main)
npm run verify:telemetry
npm run verify:audit
npm run verify:boundaries
npm run verify:reconciliation
npm run verify:issues
npm run verify:readiness

# Live synthetic probe for #269 (HEALTHY on both networks; a viewer is refused with exit code 2)
UTIX_USER_ROLE=service npm run synthetic:monitor -- --network testnet
UTIX_USER_ROLE=service npm run synthetic:monitor -- --network mainnet
npm run synthetic:monitor

The fuzz suite was confirmed to detect the #268 defects: with the three fixes
reverted, 4 fuzz tests fail.

Scope Notes

  • Core-only: no feature slice, route, page or UI component changed.
  • There is no backend or database. The cache is in-memory, and the probe is
    read-only against public Horizon.
  • No new npm dependencies. package-lock.json is unchanged.
  • No breaking changes:
  • Pre-existing failures on main were left as they are, because they are
    unrelated to these issues:
    • 21 unit tests;
    • 5 slices in verify:features;
    • 18 tsc errors in other files;
    • npm ci lockfile drift.

…ol#270 Parcel-Protocol#271 bounded cache, synthetic monitor, fuzz and concurrency tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Oct 18, 2026

Copy link
Copy Markdown

@Oge8 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@BigDella
BigDella merged commit d2c2293 into Parcel-Protocol:main Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants