Read-Cutover Job Queries (Neo4j ↔ Neptune)
Read-Cutover Job Queries (Neo4j ↔ Neptune)
Section titled “Read-Cutover Job Queries (Neo4j ↔ Neptune)”⚠️ Partially historical (PLT-853, 2026-07). The precompute subsystem (Y6–Y9 aggregates,
GlobalAgg, global counters) and the DUE_NOW index (day-bucket reader, retry consumer, prune, catch-up singleton) were removed — they were dead (nothing read precompute output; the repurchase read uses the disjoint-shard scan, not the DUE index). Sections below that describe a precompute dependency or DUE-index read/write are no longer accurate; the repurchase Neptune read is the scan (GetAllRepurchaseCandidatesNeptune) with no precompute/DUE gate. Kept for historical context.
Scope. The scheduled read jobs whose Neo4j→Neptune cutover is gated by PLT-853, with the production Neo4j and Neptune queries side by side, the condition each needs to run (precompute dependency, query-speed posture), and the scheduled time. Companion to the result-consistency doc
~/src/doc/consumer-graph-worker/plt853-read-cutover-readiness.md(kept out-of-repo) andplt853-due-index-investigation.md.As of
main@aaaa9f8(2026-06-28). Queries are quoted from the embedded cyphers / Go readers; verify against the cited file before relying on exact text — the read paths are under active change.
TL;DR — what cuts over, and what gates it
Section titled “TL;DR — what cuts over, and what gates it”| Job | Reads | Neo4j (prod today) | Neptune (target) | Precompute needed? | Scheduled |
|---|---|---|---|---|---|
| Repurchase nudge | base graph (PURCHASED edges) | reads_repurchase.go cursor batch | full scan repurchase_candidates_neptune.cypher (ADOPTED #292 — the Neptune read); DUE-index due_read.cypher (present, dark — fallback) | No | Daily 03:00 UTC (SchedulerRunHour=3) |
| Weekly forecast (main shelf) | base graph + denormalized scalars | reads_forecast.go GetForecastShelfRowsForUsers | does not exist yet (write-path scalars prepped by #255/#264) | No (uses per-edge scalars) | Daily 03:00 UTC sweep; per-user send personalized to local weekly slot |
| Weekly forecast (cold-start) | current: user’s own relaxed edges. intended (a): + GlobalAgg | reads_forecast.go GetColdStartShelfRows | does not exist yet | Design-dependent — see ⚠️ | same as forecast |
⚠️ CORRECTION — precompute aggregates are currently UNREAD. Verified repo-wide on
main@aaaa9f8: nothing readsGlobalAgg.top_products_csvor theu.top_*_csv/u.*_countaggregates (only precompute writers + comments reference them). The forecast cold-start (GetColdStartShelfRows) reads the user’s own relaxedPURCHASEDedges (r.times>=1+allowedLeafallowlist), not GlobalAgg. GlobalAgg-backed cold-start is the intended design (runner.go: “Phase-3 precompute-pattern reads consume”; guide §3.3.2/§9.1), unimplemented on both engines. So whether precompute gates the forecast cutover is a branch-#3 choice: (a) build Neptune cold-start to read GlobalAgg → precompute critical; (b) port the current relaxed query → no precompute dependency. Rows/§2b below marked “GlobalAgg” describe design (a), not shipped code.
The cutover-blocking gates are correctness/wiring, not speed (speed is solved — see the readiness doc’s ⭐ summary):
- GAP-10: repurchase Neo4j-vs-Neptune candidate-set parity not yet diffed.
- Forecast has no Neptune read implemented yet; cold-start parity depends on prod precompute being enabled (currently DARK).
- Precompute scheduled full pass is
ModeFull, notreconcile(reconcile is manual-only) — see §3.
1. Repurchase nudge
Section titled “1. Repurchase nudge”When: daily at 03:00 UTC (cmd/unified-worker/config.go:SchedulerRunHour, prod run_hour: 3). Once-a-day ⇒ candidate set only needs ≤24h freshness — central to the scan-vs-index debate below.
Read target flag: repurchase_read_target (internal/notification/repurchase/reader.go:NewCandidateReader); default/prod = neo4j.
Precompute dependency: none — reads live PURCHASED edge properties directly.
1a. Neo4j — production (pkg/client/reads_repurchase.go::GetRepurchaseCandidatesBatch)
Section titled “1a. Neo4j — production (pkg/client/reads_repurchase.go::GetRepurchaseCandidatesBatch)”Cursor-paginated over all users (BatchSize=5000); Gift-Cards leaf exclusion applied post-pagination in reader.go (PLT-536, avoids index flip).
MATCH (u:User)-[r:PURCHASED]->(p:Product)WHERE r.times >= 2 AND u.user_id >= $cursor AND p.category_hierarchy IS NOT NULL AND size(p.category_hierarchy) > 0 AND NOT p.category_hierarchy[0] IN $blockedTier1CategoryIDs AND r.last >= datetime() - duration({days: $lookbackDays}) AND r.avg_interval_days > 0 AND duration.inDays(r.last, datetime()).days >= toInteger(r.avg_interval_days * $skipRecentThreshold) AND reduce(earliest = r.timestamps[0], ts IN r.timestamps | CASE WHEN ts < earliest THEN ts ELSE earliest END) <= datetime() - duration({days: $minHistoryDays}) AND r.last + duration({days: toInteger(r.avg_interval_days)}) >= datetime() + duration({hours: $leadHours}) AND r.last + duration({days: toInteger(r.avg_interval_days)}) < datetime() + duration({hours: $leadHours * 2})RETURN u.user_id AS userID, u.timezone AS timezone, p.product_id AS productID, p.name AS productName, p.brand AS brand, p.category AS category, p.category_hierarchy AS categoryHierarchy, r.times AS purchaseCount, r.avg_interval_days AS avgIntervalDays, r.last AS lastPurchaseDate, r.timestamps AS timestampsORDER BY u.user_id, r.last DESCLIMIT $limit1b. Neptune — repurchase read (RESOLVED by #292: full scan; DUE-index kept dark as fallback)
Section titled “1b. Neptune — repurchase read (RESOLVED by #292: full scan; DUE-index kept dark as fallback)”RESOLVED (#292). The repurchase Neptune read is the full scan (Option B) — restored + wired as the
repurchase_read_target=neptunereader. The DUE-index read (Option A) remains inmainbut is dark/unused, retained only as a warm fallback (see §“What stays running”). Both designs are documented below for reference.
Option A — DUE-index read (present in main, DARK/UNUSED — retained as fallback, NOT the active read). pkg/client/cypher/due_read.cypher, still callable via reads_repurchase_neptune.go. Touches only the DueDay buckets in the due window, then re-validates each :DUE edge against the live PURCHASED edge (the index is an over-approximation; stale edges are filtered, never mis-nudge). Composite (user_id, product_id) cursor; LIMIT applied after re-validation.
MATCH (d:DueDay) WHERE d.`~id` IN $dueDayIdsMATCH (d)-[due:DUE]->(u:User)WHERE u.user_id > $curUser OR (u.user_id = $curUser AND due.product_id > $curProduct)MATCH (u)-[r:PURCHASED]->(p:Product) WHERE p.product_id = due.product_id AND r.times >= 2 AND r.avg_interval_days > 0 AND p.category_hierarchy_csv IS NOT NULL AND p.category_hierarchy_csv <> ''WITH u, due, p, r, split(p.category_hierarchy_csv, ',') AS catsWHERE NOT cats[0] IN $blockedTier1 AND NOT cats[size(cats)-1] IN $globalBlockedLeaf // Gift Cards; Neptune has no negative indexing AND r.last_event_epoch_ms >= $lookbackFloorMs AND r.first_event_epoch_ms <= $minHistoryCeilMs AND ($nowMs - r.last_event_epoch_ms) >= toInteger(r.avg_interval_days * $skipThreshold) * 86400000 AND (r.last_event_epoch_ms + toInteger(r.avg_interval_days)*86400000) >= $dueFloorMs AND (r.last_event_epoch_ms + toInteger(r.avg_interval_days)*86400000) < $dueCeilMsWITH u, due, p, r ORDER BY u.user_id ASC, due.product_id ASC LIMIT $pageSizeRETURN u.user_id AS userID, u.timezone AS timezone, p.product_id AS productID, p.name AS productName, p.brand AS brand, p.category AS category, p.category_hierarchy_csv AS categoryHierarchyCsv, r.times AS purchaseCount, r.avg_interval_days AS avgIntervalDays, r.last_event_epoch_ms AS lastPurchaseEpochMs, r.timestamps_csv AS timestampsCsv- Cost: the read is cheap, but it requires the DUE-write maintenance apparatus —
UpdateProductDueBucketper receipt (PLT-903), asingleton:trueDUE-writer (sharedDueDaynodes ⇒ CME ⇒ forced single writer), a backfill Kafka topic + resumable enqueuer (cmd/due-backfill-enqueuer), prune, and the async shadow. That live-write path is where nearly every migration incident originated (#260 shadow-drop, the 92–98% writer saturation sweep before PLT-903).
Option B — full scan (ADOPTED — the active Neptune repurchase read as of #292). repurchase_candidates_neptune.cypher (restored from commit 7a61ce7); concurrency=6, userBatch≤1000 (OOMs at 2000). Scans every user every run — ~32 min @ 151K = parity with today’s prod Neo4j cadence, ~3.5h @ the 1M target. No index, no DUE-write machinery. Returns the full category_hierarchy_csv + timestamps_csv (parity with the Neo4j read).
- Why it’s attractive: the read is daily (≤24h freshness), so the index’s sub-second-freshness maintenance — and all its incident surface — is over-built for the need. Cutting over on the scan lets us flip reads without running/maintaining the DUE index.
DECISION (RESOLVED by #292). Adopted Option B (scan): the Neptune repurchase read is the scan (restored from
7a61ce7), wired behindrepurchase_read_target. The DUE-index read + its write apparatus stay inmainbut dark — kept as a warm fallback, not retired in this PR (the config gate still requiresdue_now.enabled=true, andwrite_target=bothkeeps the DUE buckets current so the fallback stays viable). Loosening the gate + removing the DUE-write machinery is a gated follow-up (see §“What stays running”), after the scan soaks in prod.
2. Weekly forecast
Section titled “2. Weekly forecast”When: evaluated in the daily 03:00 UTC sweep (weeklyforecast handler ShouldRun()==true every day); each user’s actual send is personalized to a local weekly slot (send_hour_local: 8), deduped per ISO week in Valkey (forecastWeeklyMarkTTL=8d). Prod config weekly_forecast.enabled: true, shelf_size:10, user_batch_size:500 (~9s/500-user batch).
Read target: Neo4j only — no forecast_read_target flag and no Neptune forecast read exists yet (PR #264: “there’s no Neptune forecast read yet”). #255 + #264 prepped the Neptune write path so a future Neptune read can use denormalized scalars instead of Neptune-unsupported reduce()/array ops.
2a. Main shelf — Neo4j (pkg/client/reads_forecast.go::GetForecastShelfRowsForUsers)
Section titled “2a. Main shelf — Neo4j (pkg/client/reads_forecast.go::GetForecastShelfRowsForUsers)”User-anchored (MATCH users → CALL{} per-user traversal), likelihood-ranked top-K per user. The #255/#264 optimization denormalizes Product.category_tier1/category_leaf (+ indexes) and PURCHASED.first_day_int/last_day_int/predicted_shopping_day_int to kill the per-edge reduce()/duration() compute (was the ~45 min / 150K candidate-fetch timeout); reads use coalesce(scalar, <old expr>) so un-backfilled data still works.
MATCH (u:User) WHERE u.user_id IN $userIDsCALL { WITH u MATCH (u)-[r:PURCHASED]->(p:Product) WHERE r.times >= 2 AND p.category_hierarchy IS NOT NULL AND size(p.category_hierarchy) > 0 AND NOT p.category_hierarchy[0] IN $blockedTier1CategoryIDs AND NOT p.category_hierarchy[-1] IN $globalBlockedLeafIDs AND NOT p.category_hierarchy[-1] IN $weeklyForecastBlockedLeafIDs AND r.last >= datetime() - duration({days: $lookbackDays}) AND r.avg_interval_days > 0 AND reduce(earliest = r.timestamps[0], ts IN r.timestamps | CASE WHEN ts < earliest THEN ts ELSE earliest END) <= datetime() - duration({days: $minHistoryDays}) AND r.last + duration({days: toInteger(r.avg_interval_days)}) <= datetime() + duration({days: $windowDays}) AND r.last + duration({days: toInteger(r.avg_interval_days)}) >= datetime() - duration({days: $overdueGraceDays}) WITH r, p, 1.0 / (1.0 + (toFloat(duration.inDays(r.last, datetime()).days)) / (r.avg_interval_days + 1)) AS likelihood ORDER BY likelihood DESC LIMIT $kPerUser RETURN r, p, likelihood}RETURN u.user_id AS userID, u.timezone AS timezone, u.zip_code AS zipCode, p.product_id AS productID, p.name AS productName, p.brand AS brand, p.category AS category, p.category_hierarchy AS categoryHierarchy, r.times AS purchaseCount, r.avg_interval_days AS avgIntervalDays, r.last AS lastPurchaseDate, r.timestamps AS timestamps, likelihood AS likelihoodORDER BY userID, likelihood DESC- Neptune: not implemented. When ported, it must read the
*_day_intscalars (#264 populates them on the NeptunePURCHASEDwrite path) — Neptune has no array properties so thereduce(... r.timestamps[0] ...)fallback is impossible.
2b. Cold-start — Neo4j (reads_forecast.go::IsColdStart + GetColdStartShelfRows)
Section titled “2b. Cold-start — Neo4j (reads_forecast.go::IsColdStart + GetColdStartShelfRows)”Triggered when purchaseRels < cold_start_max_purchases (4) OR historyDays < cold_start_min_history_days (21). Current shipped code reads only the user’s own relaxed PURCHASED edges (r.times>=1 + $allowedLeafCategoryIDs) — it does NOT read GlobalAgg. The global-popularity (GlobalAgg) fallback is the intended design (see ⚠️ at top), unimplemented on both engines.
-- IsColdStartMATCH (u:User {user_id: $userID})-[r:PURCHASED]->()WITH count(r) AS purchaseRels, reduce(earliest = datetime(), ts IN collect(r.timestamps) | CASE WHEN size(ts)>0 AND ts[0] < earliest THEN ts[0] ELSE earliest END) AS earliestRETURN purchaseRels, duration.inDays(earliest, datetime()).days AS historyDays- Precompute dependency (design-dependent): current code has none — cold-start reads the user’s own edges. Intended design (a) would have it read
GlobalAgg.top_products_csv, making precompute a prerequisite. Pick the design in branch #3 (see ⚠️ at top).
3. Precompute — which mode feeds which read
Section titled “3. Precompute — which mode feeds which read”Maintains the Y6–Y9 aggregate columns. Singleton service (consumer-graph-worker-precompute, min=max=1). Prod DARK (PRECOMPUTE_ENABLED=false); stage enabled against 1M idemtest.
Scheduled times (cmd/unified-worker/precompute_component.go)
Section titled “Scheduled times (cmd/unified-worker/precompute_component.go)”- Incremental — daily, default 02:00 UTC (
PrecomputeIncrementalHourUTC=2). - Full — weekly, default Sunday 03:00 UTC (
PrecomputeFullWeekday=Sunday,PrecomputeFullHourUTC=3;dueTierlets full win ties). - Reconcile — manual only (
POST /admin/precompute {"mode":"reconcile"}); does not advance the incremental watermark.
WIRING GAP (flag). Validated decision (readiness doc): full/weekly pass should use reconcile (read-agg, no per-user fold → no CME, ~4.7× faster at 1M: full 1M in 7h01m, errors=0, CME-free vs ModeFull ~45% after 15h). But the scheduled weekly pass is
ModeFull, and reconcile is manual-only. To implement the decision, either schedule reconcile for the weekly tier or make the weekly tier call reconcile + tuneworkers(low, e.g. 8, to coexist with live dual-write — repurpose PR #290). Daily staysModeIncremental.
Mode → cyphers (internal/precompute/runner.go::Run)
Section titled “Mode → cyphers (internal/precompute/runner.go::Run)”| Mode | Per-user PrecomputeUser | Per-user global fold UpdateGlobalCountersForUser | End-of-pass global |
|---|---|---|---|
| Incremental (daily) | ✅ (changed users only, last_edge_change_ms > since) | ✅ precompute_global_counter_update.cypher | ComputeGlobalTopK (precompute_global_topk.cypher → precompute_global_write.cypher) |
| Full (weekly) | ✅ (all users) | ✅ | ComputeGlobalTopK |
| Reconcile (manual) | ✅ (all users) | ❌ skipped | PrecomputeGlobal full-scan read-agg (precompute_global_shard.cypher, 256 hex shards × conc 8) → precompute_global_write.cypher |
The per-user fold writes the shared per-product counters (Product.total_times/buyers_count) → CME under concurrency; reconcile avoids it entirely by recomputing global top-K from a read-aggregation. This is the crux of the full-pass = reconcile decision.
Aggregate → writer → consumer
Section titled “Aggregate → writer → consumer”| Aggregate column | Written by (cypher) | Consumed by |
|---|---|---|
u.top_repurchases_csv (Y6) | precompute_user.cypher | reference/audit — not read by the repurchase batch (it reads live PURCHASED edges) |
u.purchase_count / retailer_count / community_count / category_count (Y7) | precompute_user.cypher | Coach scorer (purchase_count, category_count); forecast diversity signals |
u.top_categories_csv (Y8) | precompute_user.cypher | reference — cold-start reads live edges, not this CSV |
GlobalAgg.top_products_csv (Y9) | precompute_global_write.cypher (fed by ComputeGlobalTopK incr/full, or PrecomputeGlobal reconcile) | No current reader (verified repo-wide). Intended (design a): weekly-forecast cold-start fallback shelf |
Product.total_times / buyers_count (counters) | precompute_global_counter_update.cypher (incr/full fold) | input to precompute_global_topk.cypher |
Key takeaway for the cutover: the repurchase read has no precompute dependency (live-edge read). The forecast read has no precompute dependency in current shipped code either — both main shelf and cold-start read PURCHASED edges, and nothing reads GlobalAgg/aggregates today. Precompute gates forecast only if branch #3 implements the intended GlobalAgg-backed cold-start (design a). So today, precompute readiness gates neither read cutover; it becomes a forecast gate only under design (a).
4. Precompute subsystem — status & cleanup posture (2026-06-28)
Section titled “4. Precompute subsystem — status & cleanup posture (2026-06-28)”The precompute code lives in three places — internal/precompute/runner.go (orchestrator: modes + worker pool), cmd/unified-worker/precompute_component.go (singleton service + scheduler), and pkg/client/precompute.go + cypher/precompute_*.cypher (Neptune methods/queries). It’s doing three distinct jobs that are easy to conflate, and on main @ aaaa9f8 none of them is on a live read path:
- Y6–Y9 aggregate job — built, validated, completely UNCONSUMED. Maintains
u.top_repurchases_csv(Y6),u.{purchase,retailer,community,category}_count(Y7),u.top_categories_csv(Y8),GlobalAgg.top_products_csv(Y9), + theProduct.total_times/buyers_countcounters feeding Y9. Dark (prodprecompute_enabled: 'false'; stage'true'for the 1M tests). No read path consumes any of these columns (verified repo-wide: noGlobalAggreader; Coach’spurchase_count=r.times, a per-edge value, notu.purchase_count). It’s substrate for the deferred GlobalAgg-backed forecast cold-start (design (a)). - DUE-bucket pruning (
PruneDueDays) — part of the DUE-index apparatus, not the aggregates. Run as a separate DuePrune scheduled task on the same singleton; deletes pastDueDaybuckets (dayInt < today). It serves the DUE-index repurchase read (due_read.cypher) — i.e. Option-A repurchase machinery. (The heavier DUE rebucket maintenance moved to event-driven per-receipt writes in PLT-903; the sweep was deleted —runner.go:41. Only the prune remained.) BackfillForecastDayInts— not the precompute job. Filed inprecompute.go(hence the confusion) but it’s #264’s standalone one-time day-int backfill, run by its own binarycmd/backfill-forecast-dayints. Not invoked by the scheduled precompute job.
One-liner: precompute is a complete, working, but currently dark/unconsumed subsystem — aggregate job (1) writes columns nothing reads, prune task (2) serves a DUE index we’re choosing not to use under (b), (3) is a misfiled backfill helper. Under the (b) cutover none of it is on the critical path.
Cleanup posture — AGREED, but GATED
Section titled “Cleanup posture — AGREED, but GATED”Under the (b) decision (repurchase = scan, DUE index unused), the DUE-index apparatus becomes cleanup-eligible: PruneDueDays/the DuePrune scheduled task, due_read.cypher, the DUE-writer singleton + backfill enqueuer (cmd/due-backfill-enqueuer) + the live per-receipt UpdateProductDueBucket write path. But do NOT remove it as part of the cutover. Gate the removal on:
- (i) the scan cutover is validated in prod (GAP-10 candidate-set parity green) and soaked (≥7 days), and
- (ii) an explicit decision that we won’t fall back to the DUE index (the investigation kept it as the fallback if scan latency ever regresses).
Until then it stays dark-but-present = cheap insurance. Track removal as a separate follow-on PR, not folded into #1/#2/#3.
Keep (do NOT clean up): the Y6–Y9 aggregate job (job 1) — it’s the deliberate substrate for the deferred design-(a) GlobalAgg cold-start; branch #2 (reconcile-weekly, dark) refines its full-pass mechanism for that future enablement. BackfillForecastDayInts (job 3) is harmless/vestigial once day-ints are backfilled; clean up opportunistically, not urgently.
5. Read-cutover validation findings (2026-06-28, prod)
Section titled “5. Read-cutover validation findings (2026-06-28, prod)”End-to-end validation on prod (in-VPC runner → prod Neptune reader + Neo4j). Three outcomes: speed solved, repurchase candidate parity root-caused to eReceipts, and the data divergence resolved as “Neptune clean / Neo4j contaminated.”
5.1 Speed — SOLVED (concurrent “03:00 scheduler” test)
Section titled “5.1 Speed — SOLVED (concurrent “03:00 scheduler” test)”Ran the repurchase scan and a weekly-forecast read concurrently on the prod 4xl reader (the realistic nightly situation):
- Forecast e2e: 51.6 min (152,239 users, errs=0) — read-shape probe (range-walk + per-user top-10 + inline epoch arithmetic; the real Neptune forecast read is branch #3, unbuilt).
- Repurchase scan: 41.7 min under concurrent load (vs 22.5 min solo), 35,484 candidates, errs=0.
- Reader peaked ~73% (declined as forecast finished); the live writer (
reader-1) stayed ~1% — reads hit theconsumer-graph-prod-writerreader replica; live dual-write is untouched. Both jobs inside the 3h handler window. - Conclusion: combined read load is feasible on the 4xl reader. Before any 4xl→8xl upsize, tune read concurrency / stagger the jobs (the precompute
workerslesson).#264day-int scalars are NOT needed for speed (bottleneck is traversal + row-volume + sort, not arithmetic).
5.2 Repurchase candidate parity — 12.8% recall, root-caused to eReceipts
Section titled “5.2 Repurchase candidate parity — 12.8% recall, root-caused to eReceipts”Neo4j vs Neptune repurchase candidates, identical params (lookback=minHistory=90, skip=0.8, lead=24h, tier1+gift-card blocklists), sample user_id range 60*:
| value | |
|---|---|
| Neo4j candidates | 2,419 |
| Neptune candidates | 2,394 |
| Intersection | 310 |
| Neo4j-cand recall in Neptune | 0.128 |
| Neo4j-only (missing from Neptune) | 87% |
| Neptune-only | 87% |
Same #users (~1,740) and same #candidates (~2,400) but ~87% different products. NOT a query bug — §1’s two reads are logically equivalent and the 310 exact matches prove the diff works. The narrow 24h due window (predicted = last_event + avg_interval_days ∈ [now+24h, now+48h]) amplifies any per-edge last_event/avg difference into large candidate churn.
5.3 Root cause — eReceipts (PLT-926), confirmed by PH-API is_digital
Section titled “5.3 Root cause — eReceipts (PLT-926), confirmed by PH-API is_digital”Per-edge cross-store compare (5 users) + PH-API is_digital test:
- Neptune missing ~31% of edges + ~47% of shared edges differ in
times/last/avg. - Decisive controls (PH
is_digital): two zero-digital users → Neo4j ≈ Neptunesum(times)(gap ±5–8%, one Neptune-higher) ⇒ physical receipts MATCH. The 100%-digital user6000548f→ Neo4j 1,037 edges vs Neptune 3 ⇒ entire deficit is eReceipts. Mixed users’ deficit scales with digital count. - → The Neptune deficit IS the eReceipts excluded by [PLT-926] (
TransformToGraphDataskipsreceipts.IsDigital[i], matching the liveDigitalReceiptDatafilter). Not a backfill bug, not missing physical, not precision.
5.4 eReceipt timestamps are unusable — validates the PLT-926 drop
Section titled “5.4 eReceipt timestamps are unusable — validates the PLT-926 drop”For 6000548f (PH source):
purchase_fto(intended order time) =4247183979→ year 2104, garbage (PLT-926: “purchase_fto unusable”).scan_tsis a bulk-ingest timestamp, not an order date: 528 digital receipts → only 224 distinct exact seconds, with up to 10 receipts sharing the identical second — batch ingestion, not 10 shopping events.- → real order dates unrecoverable ⇒
avg_interval_daysfrom eReceipts is noise ⇒ repurchase cadence on them is meaningless. Dropping eReceipts (live + PLT-926 backfill) is correct.
5.5 Neptune is CLEAN, Neo4j is CONTAMINATED (the key reframe)
Section titled “5.5 Neptune is CLEAN, Neo4j is CONTAMINATED (the key reframe)”Timeline (user-confirmed + Loki):
- PLT-926 deployed prod 2026-06-26, and the Neptune base-graph backfill was re-run 6/26 with PLT-926 active → Neptune is eReceipt-free (the correct go-forward baseline).
- Neo4j still carries LEGACY eReceipts from per-user enrollment backfills that ran before 6/26. Loki shows
6000548fwas enrolled+backfilled ~Jun 11 (request_id=backfill-…, PHlookback_days=365, 878 products / 1,840 purchases) → its eReceipt edges freeze at Jun 12;6001aecbwas backfilled ~Mar 30. Live writes never add eReceipts (ereceipt-edgelast7d=0for both users; only BTS/physical arrive live). - So the 87% divergence = Neptune RIGHT (physical-only) vs Neo4j WRONG (contaminated with bad-timestamp eReceipt nudges). “Parity with current Neo4j” is the wrong bar — Neo4j is the dirty side. Cutting repurchase reads to Neptune is a correctness win (it stops the bad-date eReceipt nudges; once reads flip, Neo4j’s contamination is bypassed).
5.6 Implications for the cutover
Section titled “5.6 Implications for the cutover”- Validate parity on PHYSICAL-only candidates with a tolerance band (not vs contaminated Neo4j). Zero-digital controls already show ~92–95% physical agreement; the residual ~5–8% is fresh-refetch timing + BTS shop-orders, amplified by the narrow 24h window — so a small tolerance is expected, not a defect.
- Product call (leaning ACCEPT): repurchase-on-Neptune excludes eReceipt-driven nudges (coverage drop, but those nudges fired on unreliable dates). Alt: restore eReceipts only after the upstream order-date fix PLT-926 references.
- Forecast reads the same
PURCHASEDedges → same eReceipt story; its wider window (~21 days vs 24h) is less timestamp-sensitive → expect higher physical recall. Forecast parity is a post-#3 step on the same physical-only basis (no new data investigation needed). Timing already validated (§5.1). - The aggregate “+5.4% PURCHASED on Neptune” count-parity was MISLEADING — it masks large per-user bidirectional divergence (ereceipt-heavy users → Neptune much lower; fresher users → Neptune higher).
Config / defaults reference
Section titled “Config / defaults reference”- Repurchase:
repurchase_read_target(defaultneo4j),BatchSize=5000,run_hour=3,lead_hours,min_history_days=90,skip_recent_threshold=0.8. - Forecast:
weekly_forecast.{shelf_size:10, min_shelf_items:3, lookback_days:180, min_history_days:21, window_days:7, overdue_grace_days:14, user_batch_size:500, cold_start_max_purchases:4, cold_start_min_history_days:21, cold_start_enabled:true}. Noforecast_read_targetyet. - Precompute:
workersdefault 64 (tune low for shared writer),pageSize=5000; shardsprefixLen=2 (256),concurrency=8,maxDepth=5;topK=50;max_failure_fraction=0.10.
Read-cutover PR topology & the flip asymmetry
Section titled “Read-cutover PR topology & the flip asymmetry”The Neo4j→Neptune read cutover ships as per-read-path flags (repurchase_read_target, weekly_forecast.read_target, coach.read_target), each a static config read at boot (env-overridable for forecast/coach; repurchase is YAML-only). The three feature PRs deliberately differ in how the flip happens, and that asymmetry is intentional:
| Path / PR | Ships in the feature PR as | Merging the feature PR | How it goes live |
|---|---|---|---|
| Repurchase (#292) | repurchase_read_target: neo4j (DARK) | no-op (reader present, unreached) | separate flip PR #295 (→ neptune, stacked on #292). Boot gate: due_now.enabled=true (satisfied). Op gate: physical-only PURCHASED parity soak (§5.6). |
| Weekly forecast (#293) | weekly_forecast.read_target: neo4j (DARK) | no-op | separate flip PR #296 (→ neptune, stacked on #293), or WEEKLY_FORECAST_READ_TARGET env. Op gate: physical-only shelf parity soak. |
| Coach (#294) | coach.read_target: neptune (flip bundled) | = the cutover for Coach-enabled users | merging #294 IS the flip — no separate PR. |
Why #294 bundles its flip while #292/#293 stay dark: Coach has a read whose Neptune behavior is not a drop-in equivalent — the A2 BrandSwap substitute discovery. The graph A2 query under-returns badly on Neptune (Jaccard 0.00–0.80 vs Neo4j; Neptune matches the full p.category string, Neo4j matches IN_CATEGORY leaf-node membership). The fix is to move A2 off the graph entirely onto the Valkey popular_substitutes snapshot (a2_source=valkey, graph-independent). Splitting Coach into “merge reader dark, then flip” would open a window where read_target=neptune is live but A2 still hits the divergent graph path. So #294 bundles read flip + timezone-from-profile-service + a2_source=valkey into one atomic, A2-safe cutover. Repurchase and forecast have no such per-read trap — their Neptune reads are equivalent to Neo4j (modulo the eReceipt data story in §5), so they can ship dark and flip later via a trivial, instantly-revertible config PR.
Operational consequence: merging #292/#293 is safe any time (dark); the cutover is the deliberate act of merging #295/#296 after the parity soak. Merging #294 is itself the cutover and routes Coach-enabled users to Neptune immediately — merge it deliberately, not as a routine dark merge. All flips are revertible (dual-write keeps both stores populated).