@@ -58,6 +58,66 @@ import {
5858 rateLimitExceededTotal ,
5959} from "./metrics.js" ;
6060
61+ // ── Merchant Config Cache ──────────────────────────────────────────────────────
62+
63+ /**
64+ * Robust in-memory LRU cache for merchant notification configs.
65+ * Reduces duplicate DB lookups across poll cycles and within a single cycle.
66+ */
67+ class MerchantConfigCache {
68+ constructor ( maxEntries = 1000 , ttlMs = 5 * 60 * 1000 ) {
69+ this . maxEntries = maxEntries ;
70+ this . ttlMs = ttlMs ;
71+ this . cache = new Map ( ) ;
72+ }
73+
74+ get ( merchantId ) {
75+ const entry = this . cache . get ( merchantId ) ;
76+ if ( ! entry ) return null ;
77+ if ( Date . now ( ) - entry . insertedAt > this . ttlMs ) {
78+ this . cache . delete ( merchantId ) ;
79+ return null ;
80+ }
81+ // LRU touch
82+ this . cache . delete ( merchantId ) ;
83+ this . cache . set ( merchantId , entry ) ;
84+ return entry . data ;
85+ }
86+
87+ set ( merchantId , data ) {
88+ if ( this . cache . has ( merchantId ) ) {
89+ this . cache . delete ( merchantId ) ;
90+ }
91+ if ( this . cache . size >= this . maxEntries ) {
92+ const oldest = this . cache . keys ( ) . next ( ) . value ;
93+ this . cache . delete ( oldest ) ;
94+ }
95+ this . cache . set ( merchantId , { data, insertedAt : Date . now ( ) } ) ;
96+ }
97+
98+ invalidate ( merchantId ) {
99+ if ( merchantId ) {
100+ this . cache . delete ( merchantId ) ;
101+ return ;
102+ }
103+ this . cache . clear ( ) ;
104+ }
105+
106+ getStats ( ) {
107+ return {
108+ size : this . cache . size ,
109+ maxEntries : this . maxEntries ,
110+ ttlMs : this . ttlMs ,
111+ } ;
112+ }
113+ }
114+
115+ const merchantConfigCache = new MerchantConfigCache ( ) ;
116+
117+ export function getMerchantConfigCacheStats ( ) {
118+ return merchantConfigCache . getStats ( ) ;
119+ }
120+
61121/** Prometheus label set identifying the Ledger Monitor's Horizon rate limiter. */
62122const RATE_LIMIT_LABELS = { endpoint : "ledger_monitor" , type : "horizon" } ;
63123
@@ -820,31 +880,52 @@ async function preloadMerchantConfigs(payments) {
820880 ] ;
821881 if ( merchantIds . length === 0 ) return cache ;
822882
823- const { data, error } = await supabase
824- . from ( "merchants" )
825- . select ( `id, ${ MERCHANT_NOTIFICATION_FIELDS } ` )
826- . in ( "id" , merchantIds ) ;
827-
828- if ( error ) {
829- logger . warn (
830- { err : error , merchantCount : merchantIds . length } ,
831- "Horizon poller: batch merchant preload failed — falling back to per-payment lookups" ,
832- ) ;
833- return cache ;
883+ const cached = [ ] ;
884+ const toFetch = [ ] ;
885+ for ( const id of merchantIds ) {
886+ const entry = merchantConfigCache . get ( id ) ;
887+ if ( entry ) {
888+ cached . push ( { id, entry } ) ;
889+ } else {
890+ toFetch . push ( id ) ;
891+ }
834892 }
835893
836- for ( const merchant of data ?? [ ] ) {
837- cache . set ( merchant . id , merchant ) ;
894+ for ( const { id , entry } of cached ) {
895+ cache . set ( id , entry ) ;
838896 }
839- // Record cache misses as null so confirmation never re-queries them.
840- for ( const id of merchantIds ) {
841- if ( ! cache . has ( id ) ) cache . set ( id , null ) ;
897+
898+ if ( toFetch . length > 0 ) {
899+ const { data, error } = await supabase
900+ . from ( "merchants" )
901+ . select ( `id, ${ MERCHANT_NOTIFICATION_FIELDS } ` )
902+ . in ( "id" , toFetch ) ;
903+
904+ if ( error ) {
905+ logger . warn (
906+ { err : error , merchantCount : toFetch . length } ,
907+ "Horizon poller: batch merchant preload failed — falling back to per-payment lookups" ,
908+ ) ;
909+ return cache ;
910+ }
911+
912+ for ( const merchant of data ?? [ ] ) {
913+ merchantConfigCache . set ( merchant . id , merchant ) ;
914+ cache . set ( merchant . id , merchant ) ;
915+ }
916+ for ( const id of toFetch ) {
917+ if ( ! cache . has ( id ) ) {
918+ merchantConfigCache . set ( id , null ) ;
919+ cache . set ( id , null ) ;
920+ }
921+ }
842922 }
843923 } catch ( err ) {
844924 logger . warn (
845925 { err } ,
846926 "Horizon poller: batch merchant preload errored — falling back to per-payment lookups" ,
847927 ) ;
928+ merchantConfigCache . invalidate ( null ) ;
848929 return new Map ( ) ;
849930 }
850931 return cache ;
@@ -859,6 +940,12 @@ async function loadMerchantNotificationConfig(merchantId, cache = new Map()) {
859940 return cache . get ( merchantId ) ;
860941 }
861942
943+ const merchant = merchantConfigCache . get ( merchantId ) ;
944+ if ( merchant !== null && merchant !== undefined ) {
945+ cache . set ( merchantId , merchant ) ;
946+ return merchant ;
947+ }
948+
862949 const { data, error } = await supabase
863950 . from ( "merchants" )
864951 . select ( MERCHANT_NOTIFICATION_FIELDS )
@@ -871,10 +958,12 @@ async function loadMerchantNotificationConfig(merchantId, cache = new Map()) {
871958 "Horizon poller: failed to load merchant notification config" ,
872959 ) ;
873960 cache . set ( merchantId , null ) ;
961+ merchantConfigCache . set ( merchantId , null ) ;
874962 return null ;
875963 }
876964
877- const merchant = data ?? null ;
878- cache . set ( merchantId , merchant ) ;
879- return merchant ;
965+ const result = data ?? null ;
966+ cache . set ( merchantId , result ) ;
967+ merchantConfigCache . set ( merchantId , result ) ;
968+ return result ;
880969}
0 commit comments