mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
15 KiB
15 KiB
Phase 04 — Dual-Write Wrappers + Storage Flag + e2e
Context Links
- Schema report §5 (dual-write mechanics)
- Code-reviewer Findings #2, #8, #18, #20, #27
- Brainstormer Finding #10 (e2e moves earlier)
- Debugger #8
src/db/create-store.js— KV factory to extend (≤80 LOC currently)src/db/create-sql-store.js— SQL factory to extend (≤80 LOC currently)scripts/stub-kv.js— needs siblingstubMongo(duck-typed, NOT a string)scripts/register.js:75—buildRegistry({MODULES, KV: stubKv, AI: stubAi})call site
Overview
- Priority: P0
- Status: pending
- Description: Wrap KV and SQL stores so writes hit BOTH backends and reads hit the configured primary. Single env flag
STORAGE_PRIMARYtoggles read source. Also lands the e2e storage-roundtrip test (moved here from phase-08 per brainstormer #10) so dual-write code is end-to-end-tested before any prod soak. This is the ONLY phase that touches the request path's data layer pre-cutover.
Key Insights
- Single source of truth:
env.STORAGE_PRIMARY∈ {kv,mongo}. Defaultkv. - Dual-write is always on when MongoDB credentials are present, regardless of
STORAGE_PRIMARY. Goal: keep secondary warm + ready for read-flip. - Writes to BOTH must be parallel via
Promise.allSettled. If secondary write fails → log error AND push the failed key onto a small KV retry queue (__retry:mongo-failed) for later drain. Throw only on primary failure. (code-reviewer #8 / debugger #8) - A separate drift-verifier cron (1/hr or 1/6h, sample N keys) drains the retry queue + spot-checks parity. Replaces the original "one-shot reconciliation" approach.
- During cutover (Phase 07), the flag flips to
mongo. After soak, secondary writes can be turned off via a second flagDUAL_WRITE(default1, set to0post-cutover). register.jsruns at deploy time — does NOT need a Mongo connection. Pass duck-typedstubMongo+STUB_SENTINELso factories short-circuit even if flags drift (code-reviewer #2).
Requirements
Functional
DualKVStoreclass implementingKVStoreinterface:get/getJSON/list→ read from primary only.put/putJSON/delete→ write to both viaPromise.allSettled. Log secondary failures with key + error AND enqueue to__retry:mongo-failed(a KV list bound to the sameenv.KVnamespace, prefix__retry:mongo-failed:). Throw only on primary failure.- Expose
_kind === "dual"sentinel for test-side identification (replaces the_implementationsarray seam — code-reviewer #27).
DualSqlStoreclass implementingSqlStoreinterface:all/first→ read primary only.run→ write both. Same fault tolerance as KV (retry-queue same namespace, prefix__retry:mongo-sql-failed:).prepare/batch→ primary only (D1 stays authoritative for legacy paths until cutover).
create-store.jsfactory updated:- Reads
env.STORAGE_PRIMARY(default"kv") andenv.DUAL_WRITE(default"1"). - If
env.MONGODB_URI === STUB_SENTINEL→ unconditionally return CFKVStore-only (deploy-time path). - If
MONGODB_URIabsent → return CFKVStore-only (legacy path). - If
DUAL_WRITE=0ANDSTORAGE_PRIMARY=mongo→ return MongoKVStore-only. - Otherwise → return
DualKVStore(primary, secondary). - Pre-design post-cutover shape (code-reviewer #20): when both flags are absent / unset, factory returns MongoKVStore-only directly (no flag inspection). Phase-07 simplification just deletes the KV branches.
- Reads
create-sql-store.jsfactory mirrors logic for SQL (usesMongoSqlStoreshim from phase-03).register.js+scripts/stub-kv.js: add duck-typedstubMongo(NOT a string) +STUB_SENTINELso deploy-time registry build doesn't crash.- Drift-verifier cron (replaces one-shot phase-05 reconciliation):
- New module
src/cron/drift-verifier.js(or co-located in misc) registered inwrangler.toml [triggers] cronsat 1/hr (configurable). - Drains
__retry:mongo-failedqueue (each entry: re-attempt the secondary write). - Spot-checks parity: sample N keys per module, hash-compare values, log mismatches.
- New module
- e2e test lands HERE (not phase-08 — brainstormer #10):
tests/e2e/storage-roundtrip.test.jsboots a fake env withMongoKVStore+MongoSqlStoreagainstfake-mongo.js, dispatches a representative wordle command + a trading insert, asserts state persisted.
Non-functional
- Each new file ≤200 LOC.
- New files:
src/db/dual-kv-store.js,src/db/dual-sql-store.js,src/cron/drift-verifier.js,tests/e2e/storage-roundtrip.test.js. - All new exports JSDoc'd.
- Behavior must be deterministic given env flags (testable via fake env).
- Logging via
console.warn/console.errorwith structured fields ({ phase, op, key, err }).
Architecture
Read/write flow (during dual-write window)
sequenceDiagram
participant H as Handler
participant F as createStore (factory)
participant D as DualKVStore
participant K as CFKVStore (primary)
participant M as MongoKVStore (secondary)
participant Q as KV retry queue
H->>F: createStore("wordle", env)
F->>F: read env.STORAGE_PRIMARY (=kv) + env.DUAL_WRITE (=1)
F-->>H: DualKVStore(K, M)
H->>D: getJSON("games:42")
D->>K: getJSON
K-->>D: doc
D-->>H: doc
H->>D: putJSON("games:42", v, {ttl: 86400})
par parallel
D->>K: putJSON
and
D->>M: putJSON
end
K-->>D: ok
M--xD: error (caught, logged)
D->>Q: enqueue(__retry:mongo-failed:games:42)
D-->>H: ok (primary succeeded)
Drift-verifier cron flow
flowchart LR
Cron[1/hr cron] --> V[drift-verifier]
V -->|drain| Q[(KV: __retry:mongo-failed)]
V -->|sample N keys| K[(CF KV)]
V -->|sample N keys| M[(Atlas)]
V -->|hash compare| Log[CF Observability]
V -->|retry secondary| M
Flag matrix
STORAGE_PRIMARY |
DUAL_WRITE |
MONGODB_URI |
Result |
|---|---|---|---|
(unset) or kv |
1 (default) |
set | DualKV: read KV, write both |
(unset) or kv |
0 |
any | CFKVStore only (legacy / rollback) |
mongo |
1 |
set | DualKV: read Mongo, write both (cutover phase) |
mongo |
0 |
set | MongoKVStore only (post-cutover) |
| any | any | unset | CFKVStore only (Phase 02/03 not deployed yet) |
| any | any | === STUB_SENTINEL | CFKVStore only (deploy-time register path) |
Stub for register (duck-typed; code-reviewer #2)
// scripts/stub-kv.js — sketch
export const STUB_SENTINEL = "__stub_mongo__";
export const stubMongo = {
// duck-typed MongoClient surface that no-ops without network IO
db() { return { collection: () => { throw new Error("stubMongo: no IO"); } }; },
connect: async () => undefined,
close: async () => undefined,
};
// register.js passes env.MONGODB_URI = STUB_SENTINEL; factories short-circuit on sentinel
Test: assert zero MongoClient.connect() calls when stub is used (vi.spyOn(MongoClient.prototype, 'connect')).
Rollback semantics (code-reviewer #18)
Rollback to KV-primary AFTER any Mongo-primary period requires reverse-backfill. During the Mongo-primary window, secondary writes to KV may have failed silently and KV may be missing rows that exist only in Mongo. Cross-link to Phase 07 reverse-backfill scripts (which become Stage-2 prerequisites — see phase-07 step 11 prereq).
Related Code Files
CREATE
/config/workspace/tiennm99/miti99bot/src/db/dual-kv-store.js/config/workspace/tiennm99/miti99bot/src/db/dual-sql-store.js/config/workspace/tiennm99/miti99bot/src/cron/drift-verifier.js/config/workspace/tiennm99/miti99bot/tests/db/dual-kv-store.test.js/config/workspace/tiennm99/miti99bot/tests/db/dual-sql-store.test.js/config/workspace/tiennm99/miti99bot/tests/e2e/storage-roundtrip.test.js
MODIFY
/config/workspace/tiennm99/miti99bot/src/db/create-store.js— read flags, branch, honor STUB_SENTINEL./config/workspace/tiennm99/miti99bot/src/db/create-sql-store.js— same./config/workspace/tiennm99/miti99bot/scripts/stub-kv.js— addstubMongo(duck-typed) +STUB_SENTINEL./config/workspace/tiennm99/miti99bot/scripts/register.js:75— passMONGODB_URI: STUB_SENTINEL, STORAGE_PRIMARY: "kv", DUAL_WRITE: "0"in env stub./config/workspace/tiennm99/miti99bot/wrangler.toml— add[vars] STORAGE_PRIMARY = "kv",DUAL_WRITE = "1"; add 1/hr cron for drift-verifier.
DELETE
- (none)
Implementation Steps
- Grep
env.KVandenv.DBusage outsidesrc/db/— should be zero (encapsulated). Confirm. - Create
src/db/dual-kv-store.js:- Constructor
(primary, secondary, retryQueue, logger=console). - 6 methods. Reads → primary. Writes →
Promise.allSettledwith secondary errors logged AND enqueued toretryQueue. - Expose
_kind = "dual"(code-reviewer #27). - JSDoc.
- Constructor
- Create
src/db/dual-sql-store.js— same shape, SqlStore interface. - Create
src/cron/drift-verifier.js:- Cron handler signature
(event, ctx).ctx.db / ctx.sqlprovide the dual store. - Drain
__retry:mongo-failed(and SQL queue) — re-attempt secondary writes; remove on success. - Sample N keys per module via dual store; hash-compare; log mismatches.
- Tunable N via
env.DRIFT_SAMPLE_N(default 50).
- Cron handler signature
- Modify
src/db/create-store.js:- Short-circuit on STUB_SENTINEL (code-reviewer #2).
- Branch on
env.STORAGE_PRIMARY,env.DUAL_WRITE, presence ofenv.MONGODB_URI. - When constructing MongoKVStore, pass collection name (sanitized: replace
-with_). - Keep the existing prefixed-wrapper closure intact.
- Comment the post-cutover shape (code-reviewer #20):
// post-Phase-07: this entire function returns MongoKVStore-only; KV branches removed.
- Modify
src/db/create-sql-store.jsmirrored. UseMongoSqlStoreshim from phase-03; trading module's init wiresMongoTradesStoredirectly viatradesStoreparam. - Add
stubMongo+STUB_SENTINELtoscripts/stub-kv.js. Updatescripts/register.js:75to passMONGODB_URI: STUB_SENTINEL, STORAGE_PRIMARY: "kv", DUAL_WRITE: "0". - Update
wrangler.toml[vars]block to declareSTORAGE_PRIMARY = "kv",DUAL_WRITE = "1",DRIFT_SAMPLE_N = "50". Add cron"0 * * * *"(or"0 */6 * * *"if log volume is a concern). - Tests:
dual-kv-store.test.js: write succeeds when both succeed; write succeeds when secondary fails (logs + enqueues retry); write fails when primary fails; reads always from primary;_kind === "dual".dual-sql-store.test.js: same pattern.- stubMongo regression (code-reviewer #2): construct factory with
STUB_SENTINEL+ every flag combo;vi.spyOn(MongoClient.prototype, 'connect')asserts ZERO calls. - Integration test: factory with all flag combos returns correct concrete type (verify via
_kindsentinel). - e2e test
tests/e2e/storage-roundtrip.test.js: boot fake env (fake-mongo), build registry, dispatch a wordle command (KV path) + trading insert (SQL path), assert persistence. - Update register dry-run test to confirm command list still derives.
- Run
npm test,npm run lint,npm run register:dry. All pass.
Todo List
dual-kv-store.jscreated (with retry-queue +_kindsentinel)dual-sql-store.jscreatedsrc/cron/drift-verifier.jscreated + cron registered in wrangler.tomlcreate-store.jsreads env flags + honors STUB_SENTINEL + post-cutover shape commentedcreate-sql-store.jsmirrors logic- Duck-typed
stubMongo+STUB_SENTINELadded tostub-kv.js register.jspasses Mongo stub + flagswrangler.tomldeclaresSTORAGE_PRIMARY+DUAL_WRITE+DRIFT_SAMPLE_N+ drift cron- All test files written, passing
- stubMongo never reaches MongoClient.connect asserted in test
- e2e storage-roundtrip test passes (wordle KV + trading SQL)
npm run register:drysucceedsnpm testpassesnpm run lintpasses- Manual smoke:
wrangler devwithMONGODB_URIset +DUAL_WRITE=1→ write to wordle module → verify both KV and Mongo received it
Success Criteria
- All 6 flag/sentinel combos behave per matrix.
- Secondary failure does NOT fail user-facing request; failure IS logged AND enqueued to retry queue.
register:drycontinues to work without Mongo creds; zero MongoClient.connect calls.- Drift-verifier cron drains retry queue + flags any divergence ≥ threshold.
- e2e roundtrip passes for both KV (wordle) and SQL (trading) paths against
fake-mongo.
Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Secondary write silently masks data divergence | M | H | Retry queue + drift-verifier cron (1/hr). Phase 06 monitors error rate. |
| Write amplification doubles latency on every put | H | M | Promise.allSettled parallel; latency = max(KV, Mongo). Cold p99 = 1500ms — flagged in Phase 06 abort gate. |
register:dry connects to Atlas and fails |
L | H | STUB_SENTINEL short-circuit; regression-tested in step 9. |
| Flag drift between wrangler.toml and .env.deploy | M | M | Phase 08 docs include checklist; lint script checks both files declare STORAGE_PRIMARY. |
MONGODB_URI missing in prod → factory falls back silently |
L | H | Boot-time assertion: if STORAGE_PRIMARY=mongo but URI absent (and not STUB_SENTINEL), throw on first request. |
| Order-of-operations bug: read after write reads stale primary | L | M | Same-isolate writes to primary block reads (single-threaded JS); no interleaving issue. |
| Mongo write fails because collection didn't exist + index not created | L | M | MongoKVStore _ensureIndex creates lazily; first write triggers creation. Tested in Phase 02. |
| Drift-verifier cron itself burns M0 ops | L | L | Default 1/hr × N=50 = ~1200 reads/day, well under M0 envelope. Tunable via DRIFT_SAMPLE_N. |
| Rollback to KV-primary after Mongo-primary loses data | M | H | Reverse-backfill scripts (phase-07) are Stage-2 prerequisites (code-reviewer #18). Cross-linked. |
Security Considerations
- Logged errors must NOT include the document
value(PII risk for trading). - Retry-queue values may contain document blobs — same redaction rules for any cron-driven log emission.
STORAGE_PRIMARYandDUAL_WRITEare public-readable env vars (inwrangler.toml). Acceptable — they're routing flags, not secrets.STUB_SENTINELis a public string; safe — not a credential.
Rollback (this phase only)
- Set
DUAL_WRITE=0in wrangler.toml[vars]. Redeploy. - CFKVStore + CFSqlStore become sole path. Identical to pre-Phase-04 behavior.
- Disable drift-verifier cron (remove from wrangler.toml triggers).
- (Optional) Revert phase commits if rollback is permanent.
Next Steps
- Blocks: Phase 05 (backfill must run with dual-write live so concurrent writes don't bypass Mongo).
- Unblocks: Phase 05.