@gkoos/caracal 0.1.0 → 0.1.1

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/dist/redis.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/coordination/redis/client.ts","../src/coordination/redis/eval-script.ts","../src/coordination/redis/keys.ts","../src/coordination/redis/scripts.ts","../src/coordination/redis/bulkhead.ts","../src/coordination/redis/circuit-breaker.ts"],"names":["createHash"],"mappings":";;;;AAEO,IAAM,2BAAA,GAAN,cAA0C,KAAA,CAAM;AAAA,EAC5C,YAAA,GAAe,aAAA;AAAA,EACxB,YAAY,KAAA,EAAgB;AAC1B,IAAA,KAAA,CAAM,gEAAA,EAAkE;AAAA,MACtE;AAAA,KACD,CAAA;AACD,IAAA,IAAA,CAAK,IAAA,GAAO,6BAAA;AAAA,EACd;AACF;AAEA,SAAS,gBAAgB,OAAA,EAEhB;AACP,EAAA,OAAA,CAAQ,EAAA,CAAG,SAAS,MAAM;AAAA,EAE1B,CAAC,CAAA;AACH;AAGO,SAAS,wBAAA,CACd,GAAA,EACA,cAAA,GAAiB,GAAA,EACV;AACP,EAAA,IAAI,CAAC,MAAA,CAAO,aAAA,CAAc,cAAc,KAAK,cAAA,GAAiB,CAAA;AAC5D,IAAA,MAAM,IAAI,WAAW,2CAA2C,CAAA;AAClE,EAAA,MAAM,MAAA,GAAS,IAAI,KAAA,CAAM,GAAA,EAAK;AAAA,IAC5B,WAAA,EAAa,IAAA;AAAA,IACb,kBAAA,EAAoB,KAAA;AAAA,IACpB,oBAAA,EAAsB,CAAA;AAAA,IACtB,6BAAA,EAA+B,KAAA;AAAA,IAC/B,cAAA;AAAA,IACA,cAAA,EAAgB,cAAA;AAAA,IAChB,eAAe,CAAC,OAAA,KAAY,KAAK,GAAA,CAAI,OAAA,GAAU,IAAI,GAAI;AAAA,GACxD,CAAA;AACD,EAAA,eAAA,CAAgB,MAAM,CAAA;AACtB,EAAA,OAAO,MAAA;AACT;AAkBO,SAAS,+BAAA,CACd,KAAA,EACA,cAAA,GAAiB,GAAA,EACR;AACT,EAAA,IAAI,CAAC,MAAA,CAAO,aAAA,CAAc,cAAc,KAAK,cAAA,GAAiB,CAAA;AAC5D,IAAA,MAAM,IAAI,WAAW,2CAA2C,CAAA;AAClE,EAAA,IAAI,CAAC,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,IAAK,MAAM,MAAA,KAAW,CAAA;AAC5C,IAAA,MAAM,IAAI,WAAW,mDAAmD,CAAA;AAC1E,EAAA,MAAM,UAAU,IAAI,OAAA,CAAQ,CAAC,GAAG,KAAK,CAAA,EAAG;AAAA,IACtC,WAAA,EAAa,IAAA;AAAA,IACb,kBAAA,EAAoB,KAAA;AAAA,IACpB,sBAAsB,CAAC,OAAA,KAAY,KAAK,GAAA,CAAI,OAAA,GAAU,IAAI,GAAI,CAAA;AAAA,IAC9D,YAAA,EAAc;AAAA,MACZ,cAAA;AAAA,MACA,cAAA,EAAgB,cAAA;AAAA,MAChB,oBAAA,EAAsB,CAAA;AAAA,MACtB,6BAAA,EAA+B;AAAA;AACjC,GACD,CAAA;AACD,EAAA,eAAA,CAAgB,OAAO,CAAA;AACvB,EAAA,OAAO,OAAA;AACT;ACvDA,IAAM,YAAA,uBAAmB,GAAA,EAAoB;AAGtC,SAAS,UAAU,MAAA,EAAwB;AAChD,EAAA,MAAM,MAAA,GAAS,YAAA,CAAa,GAAA,CAAI,MAAM,CAAA;AACtC,EAAA,IAAI,MAAA,KAAW,QAAW,OAAO,MAAA;AACjC,EAAA,MAAM,GAAA,GAAM,WAAW,MAAM,CAAA,CAAE,OAAO,MAAM,CAAA,CAAE,OAAO,KAAK,CAAA;AAC1D,EAAA,YAAA,CAAa,GAAA,CAAI,QAAQ,GAAG,CAAA;AAC5B,EAAA,OAAO,GAAA;AACT;AAEA,SAAS,aAAa,KAAA,EAAwB;AAC5C,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,EAAU,OAAO,KAAA;AACtC,EAAA,IAAI,KAAA,YAAiB,KAAA,EAAO,OAAO,KAAA,CAAM,OAAA;AACzC,EAAA,OAAO,EAAA;AACT;AAiBA,eAAsB,UAAA,CACpB,MAAA,EACA,MAAA,EACA,YAAA,EAAA,GACG,IAAA,EACe;AAClB,EAAA,MAAM,UAAU,MAAA,CAAO,OAAA;AACvB,EAAA,IAAI,OAAO,YAAY,UAAA,EAAY;AACjC,IAAA,OAAO,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,YAAA,EAAc,GAAG,IAAI,CAAA;AAAA,EAClD;AACA,EAAA,IAAI;AACF,IAAA,OAAO,MAAM,QAAQ,IAAA,CAAK,MAAA,EAAQ,UAAU,MAAM,CAAA,EAAG,YAAA,EAAc,GAAG,IAAI,CAAA;AAAA,EAC5E,SAAS,KAAA,EAAO;AACd,IAAA,IAAI,CAAC,YAAA,CAAa,KAAK,EAAE,QAAA,CAAS,UAAU,GAAG,MAAM,KAAA;AACrD,IAAA,OAAO,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,YAAA,EAAc,GAAG,IAAI,CAAA;AAAA,EAClD;AACF;AC5DO,SAAS,gBACd,SAAA,EACA,MAAA,EACA,SAAA,EACA,KAAA,EACA,SAAS,QAAA,EACD;AACR,EAAA,KAAA,MAAW,SAAS,CAAC,SAAA,EAAW,QAAQ,SAAA,EAAW,KAAA,EAAO,MAAM,CAAA,EAAG;AACjE,IAAA,IACE,OAAO,KAAA,KAAU,QAAA,IACjB,CAAC,KAAA,CAAM,MAAK,IACZ,MAAA,CAAO,UAAA,CAAW,KAAK,CAAA,GAAI,IAAA;AAE3B,MAAA,MAAM,IAAI,SAAA;AAAA,QACR;AAAA,OACF;AAAA,EACJ;AACA,EAAA,MAAM,WAAWA,UAAAA,CAAW,QAAQ,CAAA,CACjC,MAAA,CAAO,KAAK,SAAA,CAAU,CAAC,SAAA,EAAW,MAAA,EAAQ,WAAW,KAAK,CAAC,CAAC,CAAA,CAC5D,OAAO,KAAK,CAAA;AACf,EAAA,OAAO,CAAA,YAAA,EAAe,QAAQ,CAAA,EAAA,EAAK,MAAM,CAAA,CAAA;AAC3C;;;ACvBO,IAAM,OAAA,GAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AAsBhB,IAAM,eAAA,GAAkB,CAAA;AAAA,EAAgC,OAAO;AAAA;AAAA;AAAA,8CAAA,CAAA;AAmD/D,IAAM,gBAAA,GAAmB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AAsGzB,IAAM,mBAAA,GAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AAwE5B,IAAM,oBAAA,GAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;;;ACzP7B,SAAS,gBAAA,CACd,QACA,OAAA,EACqB;AACrB,EAAA,MAAM,YAAY,OAAA,CAAQ,SAAA;AAC1B,EAAA,eAAA,CAAgB,SAAA,EAAW,UAAA,EAAY,UAAA,EAAY,UAAU,CAAA;AAC7D,EAAA,MAAM,WAAA,GAAmC;AAAA,IACvC,MAAM,OAAA,CAAQ,QAAA,EAAU,MAAA,EAAQ,KAAA,EAAO,SAAS,KAAA,EAAO;AACrD,MAAA,IACE,CAAC,CAAC,SAAA,EAAW,OAAA,EAAS,SAAS,CAAA,CAAE,QAAA,CAAS,MAAM,CAAA,IAChD,OAAO,KAAA,KAAU,QAAA,IACjB,CAAC,KAAA,IACD,MAAM,MAAA,GAAS,GAAA;AAEf,QAAA,MAAM,IAAI,UAAU,wCAAwC,CAAA;AAC9D,MAAA,IACE,CAAC,CAAC,OAAA,EAAS,KAAK,CAAA,CAAE,KAAA;AAAA,QAChB,CAAC,KAAA,KAAU,MAAA,CAAO,aAAA,CAAc,KAAK,KAAK,KAAA,GAAQ;AAAA,WAEpD,OAAA,GAAU,KAAA;AAEV,QAAA,MAAM,IAAI,WAAW,0CAA0C,CAAA;AACjE,MAAA,MAAM,GAAA,GAAM,eAAA;AAAA,QACV,SAAA;AAAA,QACA,CAAA,SAAA,EAAY,SAAS,IAAI,CAAA,CAAA;AAAA,QACzB,QAAA,CAAS,SAAA;AAAA,QACT,QAAA,CAAS;AAAA,OACX;AACA,MAAA,IAAI;AACF,QAAA,MAAM,SAAS,MAAM,UAAA;AAAA,UACnB,MAAA;AAAA,UACA,eAAA;AAAA,UACA,CAAA;AAAA,UACA,GAAA;AAAA,UACA,MAAA;AAAA,UACA,KAAA;AAAA,UACA,OAAA;AAAA,UACA;AAAA,SACF;AACA,QAAA,IACE,CAAC,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,IACrB,MAAA,CAAO,MAAA,KAAW,CAAA,IAClB,CAAC,CAAC,CAAA,EAAG,CAAC,CAAA,CAAE,QAAA,CAAS,MAAA,CAAO,CAAC,CAAC,CAAA,IAC1B,CAAC,MAAA,CAAO,aAAA,CAAc,MAAA,CAAO,CAAC,CAAC,CAAA,IAC/B,MAAA,CAAO,CAAC,CAAA,GAAI,CAAA;AAEZ,UAAA,MAAM,IAAI,MAAM,wBAAwB,CAAA;AAC1C,QAAA,OAAO,EAAE,SAAS,MAAA,CAAO,CAAC,MAAM,CAAA,EAAG,SAAA,EAAW,MAAA,CAAO,CAAC,CAAA,EAAY;AAAA,MACpE,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF;AAAA,GACF;AACA,EAAA,OAAO,MAAA,CAAO,OAAO,WAAW,CAAA;AAClC;;;ACtCA,SAAS,WAAA,CACP,WACA,QAAA,EACqD;AACrD,EAAA,MAAM,MAAA,GAAS,CAAA,QAAA,EAAW,QAAA,CAAS,IAAI,CAAA,CAAA;AACvC,EAAA,MAAM,IAAA,GAAO,eAAA;AAAA,IACX,SAAA;AAAA,IACA,MAAA;AAAA,IACA,QAAA,CAAS,SAAA;AAAA,IACT,QAAA,CAAS,KAAA;AAAA,IACT;AAAA,GACF;AACA,EAAA,MAAM,GAAA,GAAM,eAAA;AAAA,IACV,SAAA;AAAA,IACA,MAAA;AAAA,IACA,QAAA,CAAS,SAAA;AAAA,IACT,QAAA,CAAS,KAAA;AAAA,IACT;AAAA,GACF;AACA,EAAA,MAAM,IAAA,GAAO,eAAA;AAAA,IACX,SAAA;AAAA,IACA,MAAA;AAAA,IACA,QAAA,CAAS,SAAA;AAAA,IACT,QAAA,CAAS,KAAA;AAAA,IACT;AAAA,GACF;AACA,EAAA,OAAO,CAAC,IAAA,EAAM,GAAA,EAAK,IAAI,CAAA;AACzB;AAMA,SAAS,WAAA,CAAY,KAAA,EAAgB,MAAA,EAAgB,KAAA,EAA0B;AAC7E,EAAA,IACE,CAAC,MAAM,OAAA,CAAQ,KAAK,KACpB,KAAA,CAAM,MAAA,GAAS,UACf,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,KAAM,OAAO,MAAM,QAAA,IAAY,CAAC,OAAO,aAAA,CAAc,CAAC,CAAC,CAAA,EACnE;AACA,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,WAAW,KAAK,CAAA,mBAAA,EAAsB,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA;AAAA,KAC7D;AAAA,EACF;AACA,EAAA,OAAO,KAAA;AACT;AAEA,IAAM,WAAA,GAA8B,CAAC,QAAA,EAAU,MAAA,EAAQ,WAAW,CAAA;AAElE,SAAS,YAAY,IAAA,EAA4B;AAC/C,EAAA,MAAM,CAAA,GAAI,YAAY,IAAI,CAAA;AAC1B,EAAA,IAAI,CAAC,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,CAAA,4BAAA,EAA+B,IAAI,CAAA,CAAE,CAAA;AAC7D,EAAA,OAAO,CAAA;AACT;AAaO,SAAS,8BAAA,CACd,QACA,OAAA,EACoB;AACpB,EAAA,MAAM,EAAE,WAAU,GAAI,OAAA;AAEtB,EAAA,eAAA;AAAA,IACE,SAAA;AAAA,IACA,kBAAA;AAAA,IACA,UAAA;AAAA,IACA,UAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,MAAM,WAAA,GAAkC;AAAA,IACtC,MAAM,UAAU,QAAA,EAAU;AACxB,MAAA,MAAM,CAAC,OAAO,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AACjD,MAAA,IAAI;AACF,QAAA,MAAM,SAAS,MAAM,MAAA,CAAO,KAAA,CAAM,OAAA,EAAS,SAAS,YAAY,CAAA;AAChE,QAAA,MAAM,QAAA,GAAW,OAAO,CAAC,CAAA;AACzB,QAAA,IAAI,CAAC,UAAU,OAAO,IAAA;AACtB,QAAA,MAAM,KAAA,GAAQ,QAAA;AACd,QAAA,IAAI,CAAC,WAAA,CAAY,QAAA,CAAS,KAAK,CAAA;AAC7B,UAAA,MAAM,IAAI,KAAA,CAAM,CAAA,uBAAA,EAA0B,QAAQ,CAAA,CAAE,CAAA;AACtD,QAAA,MAAM,aAAa,QAAA,CAAS,MAAA,CAAO,CAAC,CAAA,IAAK,KAAK,EAAE,CAAA;AAChD,QAAA,OAAO,EAAE,OAAO,UAAA,EAAW;AAAA,MAC7B,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF,CAAA;AAAA,IAEA,MAAM,OAAA,CAAQ,QAAA,EAAU,MAAA,EAAQ;AAC9B,MAAA,MAAM,CAAC,OAAA,EAAS,MAAM,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AACzD,MAAA,MAAM;AAAA,QACJ,UAAA;AAAA,QACA,OAAA;AAAA,QACA,IAAA;AAAA,QACA,WAAA;AAAA,QACA,iBAAA;AAAA,QACA,yBAAA;AAAA,QACA,UAAA;AAAA,QACA;AAAA,OACF,GAAI,MAAA;AACJ,MAAA,IAAI;AACF,QAAA,MAAM,QAAQ,MAAM,UAAA;AAAA,UAClB,MAAA;AAAA,UACA,gBAAA;AAAA,UACA,CAAA;AAAA,UACA,OAAA;AAAA,UACA,MAAA;AAAA,UACA,OAAA;AAAA,UACA,UAAA;AAAA,UACA,WAAA;AAAA,UACA,iBAAA;AAAA,UACA,yBAAA;AAAA,UACA,UAAA;AAAA,UACA,MAAA;AAAA,UACA;AAAA,SACF;AACA,QAAA,MAAM,CAAA,GAAI,WAAA,CAAY,KAAA,EAAO,CAAA,EAAG,kBAAkB,CAAA;AAClD,QAAA,MAAM,CAAC,MAAA,IAAU,MAAA,EAAQ,WAAA,EAAa,cAAc,CAAA,GAAI,CAAA;AACxD,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,OAAA;AAAA,YACN,iBAAA,EAAmB;AAAA,WACrB;AAAA,QACF;AACA,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,QAAA;AAAA,YACN,aAAA,EAAe,MAAA;AAAA,YACf,WAAA;AAAA,YACA;AAAA,WACF;AAAA,QACF;AACA,QAAA,OAAO;AAAA,UACL,IAAA,EAAM,UAAA;AAAA,UACN,UAAA,EAAY,MAAA;AAAA,UACZ,WAAA;AAAA,UACA;AAAA,SACF;AAAA,MACF,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF,CAAA;AAAA,IAEA,MAAM,UAAA,CAAW,QAAA,EAAU,MAAA,EAAQ;AACjC,MAAA,MAAM,CAAC,OAAA,IAAW,QAAQ,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AAC7D,MAAA,MAAM,EAAE,UAAA,EAAY,MAAA,EAAQ,cAAA,EAAgB,iBAAgB,GAAI,MAAA;AAChE,MAAA,IAAI;AACF,QAAA,MAAM,QAAQ,MAAM,UAAA;AAAA,UAClB,MAAA;AAAA,UACA,mBAAA;AAAA,UACA,CAAA;AAAA,UACA,OAAA;AAAA,UACA,QAAA;AAAA,UACA,UAAA;AAAA,UACA,MAAA;AAAA,UACA,cAAA;AAAA,UACA;AAAA,SACF;AACA,QAAA,MAAM,CAAA,GAAI,WAAA,CAAY,KAAA,EAAO,CAAA,EAAG,qBAAqB,CAAA;AACrD,QAAA,MAAM,CAAC,MAAA,EAAQ,SAAA,EAAW,GAAA,EAAK,UAAA,EAAY,YAAY,CAAA,GAAI,CAAA;AAC3D,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,MAAM,SACJ,SAAA,KAAc,CAAA,GACV,QAAA,GACA,SAAA,KAAc,IACZ,MAAA,GACA,aAAA;AACR,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,UAAA;AAAA,YACN,MAAA;AAAA,YACA,UAAA,EAAY;AAAA,WACd;AAAA,QACF;AACA,QAAA,OAAO;AAAA,UACL,IAAA,EAAM,UAAA;AAAA,UACN,UAAA,EAAY,GAAA;AAAA,UACZ,UAAA;AAAA,UACA,cAAc,YAAA,KAAiB;AAAA,SACjC;AAAA,MACF,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF,CAAA;AAAA,IAEA,MAAM,WAAA,CAAY,QAAA,EAAU,MAAA,EAAQ;AAClC,MAAA,MAAM,CAAC,OAAA,IAAW,QAAQ,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AAC7D,MAAA,MAAM;AAAA,QACJ,UAAA;AAAA,QACA,OAAA;AAAA,QACA,UAAA;AAAA,QACA,iBAAA;AAAA,QACA,MAAA;AAAA,QACA;AAAA,OACF,GAAI,MAAA;AACJ,MAAA,IAAI;AACF,QAAA,MAAM,QAAQ,MAAM,UAAA;AAAA,UAClB,MAAA;AAAA,UACA,oBAAA;AAAA,UACA,CAAA;AAAA,UACA,OAAA;AAAA,UACA,QAAA;AAAA,UACA,UAAA;AAAA,UACA,OAAA;AAAA,UACA,UAAA;AAAA,UACA,iBAAA;AAAA,UACA,MAAA;AAAA;AAAA;AAAA,UAGA,eAAe,MAAA,GAAS;AAAA,SAC1B;AACA,QAAA,MAAM,CAAA,GAAI,WAAA,CAAY,KAAA,EAAO,CAAA,EAAG,sBAAsB,CAAA;AACtD,QAAA,MAAM,CAAC,MAAA,EAAQ,SAAA,EAAW,MAAM,CAAA,GAAI,CAAA;AACpC,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,OAAA;AAAA,YACN,UAAA,EAAY;AAAA,WACd;AAAA,QACF;AACA,QAAA,MAAM,KAAA,GAAQ,YAAY,SAAS,CAAA;AACnC,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,cAAA;AAAA,YACN,QAAA,EAAU,KAAA;AAAA,YACV,aAAA,EAAe,MAAA;AAAA,YACf,aAAA,EAAe;AAAA,WACjB;AAAA,QACF;AACA,QAAA,OAAO;AAAA,UACL,IAAA,EAAM,SAAA;AAAA,UACN,KAAA;AAAA,UACA,UAAA,EAAY;AAAA,SACd;AAAA,MACF,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF;AAAA,GACF;AAEA,EAAA,OAAO,MAAA,CAAO,OAAO,WAAW,CAAA;AAClC","file":"redis.js","sourcesContent":["import { Cluster, Redis } from \"ioredis\"\n\nexport class CoordinatorUnavailableError extends Error {\n readonly coordination = \"distributed\"\n constructor(cause: unknown) {\n super(\"Redis coordination unavailable; command outcome may be unknown\", {\n cause,\n })\n this.name = \"CoordinatorUnavailableError\"\n }\n}\n\nfunction attachErrorSink(emitter: {\n on(event: \"error\", listener: () => void): void\n}): void {\n emitter.on(\"error\", () => {\n /* Commands surface errors; never crash on an unhandled EventEmitter error. */\n })\n}\n\n/** Standalone Redis client. Caller owns it and must disconnect at shutdown. */\nexport function createCoordinationClient(\n url: string,\n commandTimeout = 1000,\n): Redis {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n const client = new Redis(url, {\n lazyConnect: true,\n enableOfflineQueue: false,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n commandTimeout,\n connectTimeout: commandTimeout,\n retryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n })\n attachErrorSink(client)\n return client\n}\n\nexport interface ClusterNode {\n readonly host: string\n readonly port: number\n}\n\n/**\n * Redis Cluster client. Caller owns it and must disconnect at shutdown.\n *\n * All coordination keys use a hash-tag so that every key for a given\n * policy+operation+scope lands on the same cluster slot. Multi-key Lua\n * scripts (circuit breaker) are therefore cluster-safe without cross-slot\n * concerns.\n *\n * Call `connect()` before use and `disconnect()` at shutdown, matching the\n * standalone client contract.\n */\nexport function createCoordinationClusterClient(\n nodes: ReadonlyArray<ClusterNode>,\n commandTimeout = 1000,\n): Cluster {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n if (!Array.isArray(nodes) || nodes.length === 0)\n throw new RangeError(\"nodes must be a non-empty array of { host, port }\")\n const cluster = new Cluster([...nodes], {\n lazyConnect: true,\n enableOfflineQueue: false,\n clusterRetryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n redisOptions: {\n commandTimeout,\n connectTimeout: commandTimeout,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n },\n })\n attachErrorSink(cluster)\n return cluster\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Minimal Lua-script capability required by the Redis coordinators.\n *\n * `eval` is always required. `evalsha` is optional so structural test doubles\n * and clients that only expose `eval` keep working; when it is missing the\n * script body is sent with `eval`.\n */\nexport interface ScriptClient {\n eval(\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n evalsha?(\n sha: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n}\n\nconst scriptHashes = new Map<string, string>()\n\n/** SHA1 of a script body. Redis keys its script cache by exactly this value. */\nexport function scriptSha(script: string): string {\n const cached = scriptHashes.get(script)\n if (cached !== undefined) return cached\n const sha = createHash(\"sha1\").update(script).digest(\"hex\")\n scriptHashes.set(script, sha)\n return sha\n}\n\nfunction errorMessage(error: unknown): string {\n if (typeof error === \"string\") return error\n if (error instanceof Error) return error.message\n return \"\"\n}\n\n/**\n * Executes a Lua script, sending the body only when it has to.\n *\n * The script cache is keyed by SHA1, so the steady state is `EVALSHA` - a\n * 40-byte hash instead of kilobytes of Lua on every call. The full body goes\n * out with `EVAL` only when the client cannot send `EVALSHA`, or when the\n * server answers `NOSCRIPT`: the script cache lost the script to a restart, a\n * `SCRIPT FLUSH`, or (Redis 7.4 and later) LRU eviction under memory pressure.\n *\n * Retrying through `EVAL` is safe because Redis rejects an unknown SHA1 before\n * executing anything, so the failed command is known not to have run. This is\n * deliberately narrower than the coordinator's command-timeout rule, where the\n * outcome is unknown and a replay is never allowed. A timeout error is not a\n * `NOSCRIPT` error and propagates unchanged.\n */\nexport async function evalScript(\n client: ScriptClient,\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n): Promise<unknown> {\n const evalsha = client.evalsha\n if (typeof evalsha !== \"function\") {\n return client.eval(script, numberOfKeys, ...args)\n }\n try {\n return await evalsha.call(client, scriptSha(script), numberOfKeys, ...args)\n } catch (error) {\n if (!errorMessage(error).includes(\"NOSCRIPT\")) throw error\n return client.eval(script, numberOfKeys, ...args)\n }\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Build a namespaced Redis key for a policy-scoped coordination slot.\n *\n * The SHA-256 hash of `[namespace, policy, operation, scope]` forms the\n * stable identity; `suffix` distinguishes multiple keys that share the\n * same identity (e.g. `:leases`, `:breaker`, `:observations`, `:probes`).\n * Default suffix is `\"leases\"` for backward compatibility with the bulkhead.\n */\nexport function coordinationKey(\n namespace: string,\n policy: string,\n operation: string,\n scope: string,\n suffix = \"leases\",\n): string {\n for (const value of [namespace, policy, operation, scope, suffix]) {\n if (\n typeof value !== \"string\" ||\n !value.trim() ||\n Buffer.byteLength(value) > 1024\n )\n throw new TypeError(\n \"Coordination identities must be nonempty strings of at most 1024 UTF-8 bytes\",\n )\n }\n const identity = createHash(\"sha256\")\n .update(JSON.stringify([namespace, policy, operation, scope]))\n .digest(\"hex\")\n return `caracal:v1:{${identity}}:${suffix}`\n}\n","// ---------------------------------------------------------------------------\n// Versioned atomic operations, internal to the Redis policy capabilities.\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Bulkhead scripts\n// ---------------------------------------------------------------------------\n\nexport const leaseV1 = `\nlocal action = ARGV[1]\nlocal token = ARGV[2]\nlocal ttl = tonumber(ARGV[3])\nlocal limit = tonumber(ARGV[4])\nlocal time = redis.call('TIME')\nlocal now = time[1] * 1000 + math.floor(time[2] / 1000)\nredis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)\nlocal existing = redis.call('ZSCORE', KEYS[1], token)\nif action == 'release' then\n return redis.call('ZREM', KEYS[1], token)\nend\nif action == 'renew' and not existing then return 0 end\nif action == 'acquire' and existing then return 1 end\nif action == 'acquire' and redis.call('ZCARD', KEYS[1]) >= limit then return 0 end\nredis.call('ZADD', KEYS[1], now + ttl, token)\nlocal latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')\nredis.call('PEXPIREAT', KEYS[1], math.ceil(tonumber(latest[2])))\nreturn 1\n`\n\n/** Returns the decision and occupancy from the same atomic transition. */\nexport const bulkheadLeaseV1 = `local function transition()\\n${leaseV1}\\nend\\nlocal allowed = transition()\\nreturn {allowed, redis.call('ZCARD', KEYS[1])}`\n\n// ---------------------------------------------------------------------------\n// Circuit-breaker scripts\n//\n// Return arrays use fixed positions so the TypeScript caller can validate\n// and decode without field names (which Lua/Redis cannot return).\n//\n// stateCode encoding: 0=closed 1=open 2=half-open\n// ---------------------------------------------------------------------------\n\n/**\n * Record one attempt outcome into the distributed sliding window.\n * Atomically opens the breaker when the failure ratio meets the threshold.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = observations sorted set (:observations)\n *\n * ARGV[1] = outcome \"success\" | \"failure\"\n * ARGV[2] = expectedGeneration integer\n * ARGV[3] = windowTtlMs observation retention window in ms\n * ARGV[4] = minimumThroughput min observations before opening\n * ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5)\n * ARGV[6] = windowSize max observations retained by count\n * ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2\n * ARGV[8] = uuid unique string for member deduplication\n *\n * Returns: {status, stateCode, generation, windowTotal, windowFailures}\n * status 0 = stale (dropped; generation or state mismatch)\n * status 1 = observed, no transition (remained closed)\n * status 2 = observed, breaker opened (stateCode=1, generation incremented)\n *\n * TTL policy:\n * OPEN state → PERSIST (no expiry). A missing key is treated as closed by\n * all scripts, so expiring an open breaker would silently admit\n * unrestricted traffic and reset the generation counter.\n * CLOSED state → max(openMs×2, windowTtlMs) TTL for eventual cleanup of idle\n * scopes. Expiring a closed key is safe *only* because the TTL\n * outlives the observation window: the window is scoped by an\n * epoch, and an epoch that outlives its members is what keeps\n * cleanup from resurrecting them.\n *\n * Epochs:\n * `generation` increments on every transition, and a brand new value is minted\n * whenever the state hash has to be recreated while observation members from a\n * previous epoch are still present (state lost to eviction or admin cleanup).\n * Window membership is decided by comparing the stored epoch, so members from\n * a superseded epoch can never be counted again, and an attempt holding a\n * pre-loss generation can never pass the staleness check. A scope that has\n * never been observed keeps generation 0.\n */\nexport const breakerObserveV1 = `\nlocal outcome = ARGV[1]\nlocal expectGen = tonumber(ARGV[2])\nlocal windowTtl = tonumber(ARGV[3])\nlocal minTP = tonumber(ARGV[4])\nlocal threshNum = tonumber(ARGV[5])\nlocal windowSize = tonumber(ARGV[6])\nlocal openMs = tonumber(ARGV[7])\nlocal uuid = ARGV[8]\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nif state ~= 'closed' then\n local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\n return {0, sc, gen, 0, 0}\nend\nif redis.call('EXISTS', KEYS[1]) == 0 then\n -- No live state hash: a scope we have never seen, or a hash that was lost\n -- while its window survived.\n if expectGen ~= 0 then\n -- The caller holds a generation this key cannot confirm.\n return {0, 0, 0, 0, 0}\n end\n if redis.call('EXISTS', KEYS[2]) == 1 then\n -- Members from a superseded epoch are still here. Mint an epoch that has\n -- never been used for this key (derived from the observation's own uuid, so\n -- no clock is involved) instead of restarting at a value those members\n -- would match.\n --\n -- Bounded to 11 hex digits (44 bits, <= 14 decimal digits) so the value\n -- prints exactly with stock Lua number formatting: the epoch is read back\n -- from the hash as a decimal string by both this script and the client, and\n -- a larger value could be written in scientific notation and break the\n -- round-trip.\n gen = tonumber(string.sub(redis.sha1hex(uuid), 1, 11), 16)\n if not gen or gen == 0 then gen = 1 end\n else\n gen = 0\n end\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'openedAt', 0, 'probeCount', 0, 'probeSuccesses', 0)\nelseif gen ~= expectGen then\n return {0, 0, gen, 0, 0}\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - windowTtl)\nlocal member = tostring(gen) .. ':' .. uuid .. ':' .. outcome\nredis.call('ZADD', KEYS[2], 'NX', now, member)\nlocal cnt = redis.call('ZCARD', KEYS[2])\nif cnt > windowSize then\n redis.call('ZREMRANGEBYRANK', KEYS[2], 0, cnt - windowSize - 1)\nend\nredis.call('PEXPIREAT', KEYS[2], now + windowTtl)\nlocal all = redis.call('ZRANGE', KEYS[2], 0, -1)\nlocal prefix = tostring(gen) .. ':'\nlocal wTotal = 0\nlocal wFail = 0\nfor _, m in ipairs(all) do\n if string.sub(m, 1, #prefix) == prefix then\n wTotal = wTotal + 1\n if string.sub(m, -8) == ':failure' then wFail = wFail + 1 end\n end\nend\nif wTotal >= minTP and wFail * 1000 >= threshNum * wTotal then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- OPEN state must never expire: a missing key is treated as closed,\n -- which would silently admit unrestricted traffic and reset the generation.\n redis.call('PERSIST', KEYS[1])\n return {2, 1, gen, wTotal, wFail}\nend\nif redis.call('EXISTS', KEYS[1]) == 1 then\n -- Still CLOSED. The cleanup TTL must not expire the state before the window\n -- it governs, otherwise retained members would survive their epoch.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\nend\nreturn {1, 0, gen, wTotal, wFail}\n`\n\n/**\n * Admit a probe attempt. Transitions OPEN→HALF_OPEN when openMs has elapsed,\n * then atomically allocates a probe token slot.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken unique UUID for this probe attempt\n * ARGV[2] = openMs determines OPEN→HALF_OPEN transition and TTL\n * ARGV[3] = halfOpenProbes maximum concurrent probe tokens\n * ARGV[4] = probeLeaseTtlMs probe token TTL in ms\n *\n * Returns: {status, stateCode, generation, probeCount, transitioned}\n * status 0 = rejected\n * stateCode 0 = breaker already CLOSED (no probe needed)\n * stateCode 1 = still OPEN (openMs not yet elapsed)\n * stateCode 2 = HALF_OPEN but probe limit reached\n * status 1 = admitted (probe token stored)\n * transitioned 1 = OPEN→HALF_OPEN transition happened in this call\n * transitioned 0 = was already HALF_OPEN\n */\nexport const breakerAdmitProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal openMs = tonumber(ARGV[2])\nlocal maxProbes = tonumber(ARGV[3])\nlocal probeLeaseTtl = tonumber(ARGV[4])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'openedAt',\n 'probeCount', 'probeSuccesses')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal openedAt = tonumber(f[3]) or 0\nlocal transitioned = 0\nif state == 'closed' then\n return {0, 0, gen, 0, 0}\nend\nif state == 'open' then\n if now - openedAt < openMs then\n return {0, 1, gen, 0, 0}\n end\n state = 'half-open'\n transitioned = 1\n redis.call('HSET', KEYS[1], 'state', 'half-open', 'probeSuccesses', 0, 'probeCount', 0)\n -- OPEN/HALF_OPEN must never expire; PERSIST removes any prior cleanup TTL.\n redis.call('PERSIST', KEYS[1])\n -- Tokens from the superseded recovery window must not consume slots in this\n -- one; their settle is dropped by the generation check anyway.\n redis.call('DEL', KEYS[2])\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)\nlocal activeProbes = redis.call('ZCARD', KEYS[2])\nif activeProbes >= maxProbes then\n return {0, 2, gen, activeProbes, transitioned}\nend\nredis.call('ZADD', KEYS[2], 'NX', now + probeLeaseTtl, probeToken)\nactiveProbes = activeProbes + 1\nredis.call('HSET', KEYS[1], 'probeCount', activeProbes)\n-- State hash must not expire while recovery probes are in flight.\nredis.call('PERSIST', KEYS[1])\n-- Probes sorted set expires naturally once the last lease elapses.\nredis.call('PEXPIREAT', KEYS[2], now + probeLeaseTtl + 1000)\nreturn {1, 2, gen, activeProbes, transitioned}\n`\n\n/**\n * Record a probe attempt outcome. Removes the probe token and applies the\n * result: failure re-opens; sufficient successes close the breaker.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken the token issued by admitProbe\n * ARGV[2] = outcome \"success\" | \"failure\"\n * ARGV[3] = expectedGeneration generation captured at admission time\n * ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close\n * ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)\n * ARGV[6] = windowTtlMs observation retention (TTL floor): the CLOSED\n * hash must outlive the window it governs\n *\n * Returns: {status, stateCode, generation}\n * status 0 = stale (token missing, lease elapsed, or generation mismatch;\n * the result is dropped and any consumed token is released)\n * status 1 = settled, no state transition (still HALF_OPEN)\n * status 2 = settled, state transition occurred\n * stateCode 0 = transitioned to CLOSED\n * stateCode 1 = transitioned back to OPEN (probe failure)\n *\n * A token carries its own deadline as its sorted-set score. When the lease has\n * elapsed the slot is already recoverable - admitProbe prunes expired tokens\n * and re-issues the slot - so the result must not count even while the member is\n * still present. The deadline is therefore checked under the same atomic call.\n */\nexport const breakerSettleProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal outcome = ARGV[2]\nlocal expectGen = tonumber(ARGV[3])\nlocal halfOpenSucc = tonumber(ARGV[4])\nlocal openMs = tonumber(ARGV[5])\nlocal windowTtl = tonumber(ARGV[6])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'probeSuccesses', 'probeCount')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal probeSucc = tonumber(f[3]) or 0\nlocal probeCnt = tonumber(f[4]) or 0\nlocal sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\nlocal deadline = redis.call('ZSCORE', KEYS[2], probeToken)\nif not deadline then\n -- Never admitted, already settled, or dropped with its recovery window.\n return {0, sc, gen}\nend\nredis.call('ZREM', KEYS[2], probeToken)\nif tonumber(deadline) <= now then\n -- The lease elapsed while the probe was still running: its slot was\n -- recoverable, so the result cannot be counted. Release the slot it still\n -- occupied so the probe count keeps matching the token set.\n probeCnt = math.max(0, probeCnt - 1)\n redis.call('HSET', KEYS[1], 'probeCount', probeCnt)\n return {0, sc, gen}\nend\nif state ~= 'half-open' or gen ~= expectGen then\n return {0, sc, gen}\nend\nprobeCnt = math.max(0, probeCnt - 1)\nif outcome == 'failure' then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- Re-opened: OPEN must not expire (see breakerObserveV1 TTL policy).\n redis.call('PERSIST', KEYS[1])\n -- In-flight probes belong to the dead generation; free their slots so the\n -- next recovery window starts with full capacity.\n redis.call('DEL', KEYS[2])\n return {2, 1, gen}\nend\nprobeSucc = probeSucc + 1\nif probeSucc >= halfOpenSucc then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'probeCount', 0, 'probeSuccesses', 0)\n -- Newly CLOSED: expire for cleanup, but never before the window it governs.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\n redis.call('DEL', KEYS[2])\n return {2, 0, gen}\nend\nredis.call('HSET', KEYS[1], 'probeSuccesses', probeSucc, 'probeCount', probeCnt)\n-- Still HALF_OPEN: must not expire while recovery is in progress.\nredis.call('PERSIST', KEYS[1])\nreturn {1, 2, gen}\n`\n","import type { BulkheadCoordinator } from \"../../core/bulkhead.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport { bulkheadLeaseV1 } from \"./scripts.js\"\nexport function redisCoordinator(\n client: RedisScriptClient,\n options: { namespace: string },\n): BulkheadCoordinator {\n const namespace = options.namespace\n coordinationKey(namespace, \"bulkhead\", \"validate\", \"validate\")\n const coordinator: BulkheadCoordinator = {\n async command(identity, action, token, leaseMs, limit) {\n if (\n ![\"acquire\", \"renew\", \"release\"].includes(action) ||\n typeof token !== \"string\" ||\n !token ||\n token.length > 256\n )\n throw new TypeError(\"Invalid bulkhead lease action or token\")\n if (\n ![leaseMs, limit].every(\n (value) => Number.isSafeInteger(value) && value > 0,\n ) ||\n leaseMs > 86400000\n )\n throw new RangeError(\"Invalid bulkhead lease duration or limit\")\n const key = coordinationKey(\n namespace,\n `bulkhead:${identity.name}`,\n identity.operation,\n identity.scope,\n )\n try {\n const result = await evalScript(\n client,\n bulkheadLeaseV1,\n 1,\n key,\n action,\n token,\n leaseMs,\n limit,\n )\n if (\n !Array.isArray(result) ||\n result.length !== 2 ||\n ![0, 1].includes(result[0]) ||\n !Number.isSafeInteger(result[1]) ||\n result[1] < 0\n )\n throw new Error(\"Invalid bulkhead reply\")\n return { allowed: result[0] === 1, occupancy: result[1] as number }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n return Object.freeze(coordinator)\n}\n","import type {\n AdmitProbeResult,\n BreakerCoordinator,\n BreakerIdentity,\n BreakerState,\n ObserveResult,\n SettleProbeResult,\n} from \"../../core/circuit-breaker.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport {\n breakerAdmitProbeV1,\n breakerObserveV1,\n breakerSettleProbeV1,\n} from \"./scripts.js\"\n\n// ---------------------------------------------------------------------------\n// Key helpers\n// ---------------------------------------------------------------------------\n\nfunction breakerKeys(\n namespace: string,\n identity: BreakerIdentity,\n): [hashKey: string, obsKey: string, probeKey: string] {\n const policy = `breaker:${identity.name}`\n const hash = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"breaker\",\n )\n const obs = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"observations\",\n )\n const prob = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"probes\",\n )\n return [hash, obs, prob]\n}\n\n// ---------------------------------------------------------------------------\n// Reply validation helpers\n// ---------------------------------------------------------------------------\n\nfunction assertArray(reply: unknown, minLen: number, label: string): unknown[] {\n if (\n !Array.isArray(reply) ||\n reply.length < minLen ||\n reply.some((v) => typeof v !== \"number\" || !Number.isSafeInteger(v))\n ) {\n throw new Error(\n `Invalid ${label} reply from Redis: ${JSON.stringify(reply)}`,\n )\n }\n return reply as unknown[]\n}\n\nconst STATE_CODES: BreakerState[] = [\"closed\", \"open\", \"half-open\"]\n\nfunction decodeState(code: number): BreakerState {\n const s = STATE_CODES[code]\n if (!s) throw new Error(`Unknown breaker state code: ${code}`)\n return s\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Policy-specific Redis coordinator for `circuitBreaker.distributed()`.\n *\n * The caller owns `client` and must connect/disconnect it independently.\n * `namespace` is prepended to every key; use a per-service, per-environment\n * value to avoid cross-deployment state collisions.\n */\nexport function redisCircuitBreakerCoordinator(\n client: RedisScriptClient,\n options: { readonly namespace: string },\n): BreakerCoordinator {\n const { namespace } = options\n // Validate namespace eagerly (coordinationKey throws on bad input).\n coordinationKey(\n namespace,\n \"breaker:validate\",\n \"validate\",\n \"validate\",\n \"breaker\",\n )\n\n const coordinator: BreakerCoordinator = {\n async readState(identity) {\n const [hashKey] = breakerKeys(namespace, identity)\n try {\n const fields = await client.hmget(hashKey, \"state\", \"generation\")\n const rawState = fields[0]\n if (!rawState) return null\n const state = rawState as BreakerState\n if (!STATE_CODES.includes(state))\n throw new Error(`Unknown breaker state: ${rawState}`)\n const generation = parseInt(fields[1] ?? \"0\", 10)\n return { state, generation }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async observe(identity, params) {\n const [hashKey, obsKey] = breakerKeys(namespace, identity)\n const {\n generation,\n outcome,\n uuid,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerObserveV1,\n 2,\n hashKey,\n obsKey,\n outcome,\n generation,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n uuid,\n )\n const r = assertArray(reply, 5, \"breakerObserveV1\") as number[]\n const [status, , newGen, windowTotal, windowFailures] = r\n if (status === 0) {\n return {\n type: \"stale\",\n currentGeneration: newGen,\n } satisfies ObserveResult\n }\n if (status === 2) {\n return {\n type: \"opened\",\n newGeneration: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n }\n return {\n type: \"observed\",\n generation: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async admitProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const { probeToken, openMs, halfOpenProbes, probeLeaseTtlMs } = params\n try {\n const reply = await evalScript(\n client,\n breakerAdmitProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n openMs,\n halfOpenProbes,\n probeLeaseTtlMs,\n )\n const r = assertArray(reply, 5, \"breakerAdmitProbeV1\") as number[]\n const [status, stateCode, gen, probeCount, transitioned] = r\n if (status === 0) {\n const reason =\n stateCode === 0\n ? \"closed\"\n : stateCode === 1\n ? \"open\"\n : \"probe-limit\"\n return {\n type: \"rejected\",\n reason,\n generation: gen,\n } satisfies AdmitProbeResult\n }\n return {\n type: \"admitted\",\n generation: gen,\n probeCount,\n stateChanged: transitioned === 1,\n } satisfies AdmitProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async settleProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const {\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n windowTtlMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerSettleProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n // Floor for the CLOSED cleanup TTL; older callers that do not pass it\n // keep the historical openMs x 2 behaviour.\n windowTtlMs ?? openMs * 2,\n )\n const r = assertArray(reply, 3, \"breakerSettleProbeV1\") as number[]\n const [status, stateCode, newGen] = r\n if (status === 0) {\n return {\n type: \"stale\",\n generation: newGen,\n } satisfies SettleProbeResult\n }\n const state = decodeState(stateCode)\n if (status === 2) {\n return {\n type: \"transitioned\",\n newState: state,\n newGeneration: newGen,\n previousState: \"half-open\",\n } satisfies SettleProbeResult\n }\n return {\n type: \"settled\",\n state,\n generation: newGen,\n } satisfies SettleProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n\n return Object.freeze(coordinator)\n}\n"]}
1
+ {"version":3,"sources":["../src/coordination/redis/client.ts","../src/coordination/redis/eval-script.ts","../src/coordination/redis/keys.ts","../src/coordination/redis/scripts.ts","../src/coordination/redis/bulkhead.ts","../src/coordination/redis/circuit-breaker.ts"],"names":["createHash"],"mappings":"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","file":"redis.js","sourcesContent":["import { Cluster, Redis } from \"ioredis\"\n\nexport class CoordinatorUnavailableError extends Error {\n readonly coordination = \"distributed\"\n constructor(cause: unknown) {\n super(\"Redis coordination unavailable; command outcome may be unknown\", {\n cause,\n })\n this.name = \"CoordinatorUnavailableError\"\n }\n}\n\nfunction attachErrorSink(emitter: {\n on(event: \"error\", listener: () => void): void\n}): void {\n emitter.on(\"error\", () => {\n /* Commands surface errors; never crash on an unhandled EventEmitter error. */\n })\n}\n\n/** Standalone Redis client. Caller owns it and must disconnect at shutdown. */\nexport function createCoordinationClient(\n url: string,\n commandTimeout = 1000,\n): Redis {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n const client = new Redis(url, {\n lazyConnect: true,\n enableOfflineQueue: false,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n commandTimeout,\n connectTimeout: commandTimeout,\n retryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n })\n attachErrorSink(client)\n return client\n}\n\nexport interface ClusterNode {\n readonly host: string\n readonly port: number\n}\n\n/**\n * Redis Cluster client. Caller owns it and must disconnect at shutdown.\n *\n * All coordination keys use a hash-tag so that every key for a given\n * policy+operation+scope lands on the same cluster slot. Multi-key Lua\n * scripts (circuit breaker) are therefore cluster-safe without cross-slot\n * concerns.\n *\n * Call `connect()` before use and `disconnect()` at shutdown, matching the\n * standalone client contract.\n */\nexport function createCoordinationClusterClient(\n nodes: ReadonlyArray<ClusterNode>,\n commandTimeout = 1000,\n): Cluster {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n if (!Array.isArray(nodes) || nodes.length === 0)\n throw new RangeError(\"nodes must be a non-empty array of { host, port }\")\n const cluster = new Cluster([...nodes], {\n lazyConnect: true,\n enableOfflineQueue: false,\n clusterRetryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n redisOptions: {\n commandTimeout,\n connectTimeout: commandTimeout,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n },\n })\n attachErrorSink(cluster)\n return cluster\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Minimal Lua-script capability required by the Redis coordinators.\n *\n * `eval` is always required. `evalsha` is optional so structural test doubles\n * and clients that only expose `eval` keep working; when it is missing the\n * script body is sent with `eval`.\n */\nexport interface ScriptClient {\n eval(\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n evalsha?(\n sha: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n}\n\nconst scriptHashes = new Map<string, string>()\n\n/** SHA1 of a script body. Redis keys its script cache by exactly this value. */\nexport function scriptSha(script: string): string {\n const cached = scriptHashes.get(script)\n if (cached !== undefined) return cached\n const sha = createHash(\"sha1\").update(script).digest(\"hex\")\n scriptHashes.set(script, sha)\n return sha\n}\n\nfunction errorMessage(error: unknown): string {\n if (typeof error === \"string\") return error\n if (error instanceof Error) return error.message\n return \"\"\n}\n\n/**\n * Executes a Lua script, sending the body only when it has to.\n *\n * The script cache is keyed by SHA1, so the steady state is `EVALSHA` - a\n * 40-byte hash instead of kilobytes of Lua on every call. The full body goes\n * out with `EVAL` only when the client cannot send `EVALSHA`, or when the\n * server answers `NOSCRIPT`: the script cache lost the script to a restart, a\n * `SCRIPT FLUSH`, or (Redis 7.4 and later) LRU eviction under memory pressure.\n *\n * Retrying through `EVAL` is safe because Redis rejects an unknown SHA1 before\n * executing anything, so the failed command is known not to have run. This is\n * deliberately narrower than the coordinator's command-timeout rule, where the\n * outcome is unknown and a replay is never allowed. A timeout error is not a\n * `NOSCRIPT` error and propagates unchanged.\n */\nexport async function evalScript(\n client: ScriptClient,\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n): Promise<unknown> {\n const evalsha = client.evalsha\n if (typeof evalsha !== \"function\") {\n return client.eval(script, numberOfKeys, ...args)\n }\n try {\n return await evalsha.call(client, scriptSha(script), numberOfKeys, ...args)\n } catch (error) {\n if (!errorMessage(error).includes(\"NOSCRIPT\")) throw error\n return client.eval(script, numberOfKeys, ...args)\n }\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Build a namespaced Redis key for a policy-scoped coordination slot.\n *\n * The SHA-256 hash of `[namespace, policy, operation, scope]` forms the\n * stable identity; `suffix` distinguishes multiple keys that share the\n * same identity (e.g. `:leases`, `:breaker`, `:observations`, `:probes`).\n * Default suffix is `\"leases\"` for backward compatibility with the bulkhead.\n */\nexport function coordinationKey(\n namespace: string,\n policy: string,\n operation: string,\n scope: string,\n suffix = \"leases\",\n): string {\n for (const value of [namespace, policy, operation, scope, suffix]) {\n if (\n typeof value !== \"string\" ||\n !value.trim() ||\n Buffer.byteLength(value) > 1024\n )\n throw new TypeError(\n \"Coordination identities must be nonempty strings of at most 1024 UTF-8 bytes\",\n )\n }\n const identity = createHash(\"sha256\")\n .update(JSON.stringify([namespace, policy, operation, scope]))\n .digest(\"hex\")\n return `caracal:v1:{${identity}}:${suffix}`\n}\n","// ---------------------------------------------------------------------------\n// Versioned atomic operations, internal to the Redis policy capabilities.\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Bulkhead scripts\n// ---------------------------------------------------------------------------\n\nexport const leaseV1 = `\nlocal action = ARGV[1]\nlocal token = ARGV[2]\nlocal ttl = tonumber(ARGV[3])\nlocal limit = tonumber(ARGV[4])\nlocal time = redis.call('TIME')\nlocal now = time[1] * 1000 + math.floor(time[2] / 1000)\nredis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)\nlocal existing = redis.call('ZSCORE', KEYS[1], token)\nif action == 'release' then\n return redis.call('ZREM', KEYS[1], token)\nend\nif action == 'renew' and not existing then return 0 end\nif action == 'acquire' and existing then return 1 end\nif action == 'acquire' and redis.call('ZCARD', KEYS[1]) >= limit then return 0 end\nredis.call('ZADD', KEYS[1], now + ttl, token)\nlocal latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')\nredis.call('PEXPIREAT', KEYS[1], math.ceil(tonumber(latest[2])))\nreturn 1\n`\n\n/** Returns the decision and occupancy from the same atomic transition. */\nexport const bulkheadLeaseV1 = `local function transition()\\n${leaseV1}\\nend\\nlocal allowed = transition()\\nreturn {allowed, redis.call('ZCARD', KEYS[1])}`\n\n// ---------------------------------------------------------------------------\n// Circuit-breaker scripts\n//\n// Return arrays use fixed positions so the TypeScript caller can validate\n// and decode without field names (which Lua/Redis cannot return).\n//\n// stateCode encoding: 0=closed 1=open 2=half-open\n// ---------------------------------------------------------------------------\n\n/**\n * Record one attempt outcome into the distributed sliding window.\n * Atomically opens the breaker when the failure ratio meets the threshold.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = observations sorted set (:observations)\n *\n * ARGV[1] = outcome \"success\" | \"failure\"\n * ARGV[2] = expectedGeneration integer\n * ARGV[3] = windowTtlMs observation retention window in ms\n * ARGV[4] = minimumThroughput min observations before opening\n * ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5).\n * The policy rejects thresholds that would round\n * to 0 or to 1000 before they reach this script\n * (see assertResolvableThreshold in core).\n * ARGV[6] = windowSize max observations retained by count\n * ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2\n * ARGV[8] = uuid unique string for member deduplication\n *\n * Returns: {status, stateCode, generation, windowTotal, windowFailures}\n * status 0 = stale (dropped; generation or state mismatch)\n * status 1 = observed, no transition (remained closed)\n * status 2 = observed, breaker opened (stateCode=1, generation incremented)\n *\n * TTL policy:\n * OPEN state → PERSIST (no expiry). A missing key is treated as closed by\n * all scripts, so expiring an open breaker would silently admit\n * unrestricted traffic and reset the generation counter.\n * CLOSED state → max(openMs×2, windowTtlMs) TTL for eventual cleanup of idle\n * scopes. Expiring a closed key is safe *only* because the TTL\n * outlives the observation window: the window is scoped by an\n * epoch, and an epoch that outlives its members is what keeps\n * cleanup from resurrecting them.\n *\n * Epochs:\n * `generation` increments on every transition, and a brand new value is minted\n * whenever the state hash has to be recreated while observation members from a\n * previous epoch are still present (state lost to eviction or admin cleanup).\n * Window membership is decided by comparing the stored epoch, so members from\n * a superseded epoch can never be counted again, and an attempt holding a\n * pre-loss generation can never pass the staleness check. A scope that has\n * never been observed keeps generation 0.\n */\nexport const breakerObserveV1 = `\nlocal outcome = ARGV[1]\nlocal expectGen = tonumber(ARGV[2])\nlocal windowTtl = tonumber(ARGV[3])\nlocal minTP = tonumber(ARGV[4])\nlocal threshNum = tonumber(ARGV[5])\nlocal windowSize = tonumber(ARGV[6])\nlocal openMs = tonumber(ARGV[7])\nlocal uuid = ARGV[8]\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nif state ~= 'closed' then\n local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\n return {0, sc, gen, 0, 0}\nend\nif redis.call('EXISTS', KEYS[1]) == 0 then\n -- No live state hash: a scope we have never seen, or a hash that was lost\n -- while its window survived.\n if expectGen ~= 0 then\n -- The caller holds a generation this key cannot confirm.\n return {0, 0, 0, 0, 0}\n end\n if redis.call('EXISTS', KEYS[2]) == 1 then\n -- Members from a superseded epoch are still here. Mint an epoch that has\n -- never been used for this key (derived from the observation's own uuid, so\n -- no clock is involved) instead of restarting at a value those members\n -- would match.\n --\n -- Bounded to 11 hex digits (44 bits, <= 14 decimal digits) so the value\n -- prints exactly with stock Lua number formatting: the epoch is read back\n -- from the hash as a decimal string by both this script and the client, and\n -- a larger value could be written in scientific notation and break the\n -- round-trip.\n gen = tonumber(string.sub(redis.sha1hex(uuid), 1, 11), 16)\n if not gen or gen == 0 then gen = 1 end\n else\n gen = 0\n end\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'openedAt', 0, 'probeCount', 0, 'probeSuccesses', 0)\nelseif gen ~= expectGen then\n return {0, 0, gen, 0, 0}\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - windowTtl)\nlocal member = tostring(gen) .. ':' .. uuid .. ':' .. outcome\nredis.call('ZADD', KEYS[2], 'NX', now, member)\nlocal cnt = redis.call('ZCARD', KEYS[2])\nif cnt > windowSize then\n redis.call('ZREMRANGEBYRANK', KEYS[2], 0, cnt - windowSize - 1)\nend\nredis.call('PEXPIREAT', KEYS[2], now + windowTtl)\nlocal all = redis.call('ZRANGE', KEYS[2], 0, -1)\nlocal prefix = tostring(gen) .. ':'\nlocal wTotal = 0\nlocal wFail = 0\nfor _, m in ipairs(all) do\n if string.sub(m, 1, #prefix) == prefix then\n wTotal = wTotal + 1\n if string.sub(m, -8) == ':failure' then wFail = wFail + 1 end\n end\nend\nif wTotal >= minTP and wFail * 1000 >= threshNum * wTotal then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- OPEN state must never expire: a missing key is treated as closed,\n -- which would silently admit unrestricted traffic and reset the generation.\n redis.call('PERSIST', KEYS[1])\n return {2, 1, gen, wTotal, wFail}\nend\nif redis.call('EXISTS', KEYS[1]) == 1 then\n -- Still CLOSED. The cleanup TTL must not expire the state before the window\n -- it governs, otherwise retained members would survive their epoch.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\nend\nreturn {1, 0, gen, wTotal, wFail}\n`\n\n/**\n * Admit a probe attempt. Transitions OPEN→HALF_OPEN when openMs has elapsed,\n * then atomically allocates a probe token slot.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken unique UUID for this probe attempt\n * ARGV[2] = openMs determines OPEN→HALF_OPEN transition and TTL\n * ARGV[3] = halfOpenProbes maximum concurrent probe tokens\n * ARGV[4] = probeLeaseTtlMs probe token TTL in ms\n *\n * Returns: {status, stateCode, generation, probeCount, transitioned}\n * status 0 = rejected\n * stateCode 0 = breaker already CLOSED (no probe needed)\n * stateCode 1 = still OPEN (openMs not yet elapsed)\n * stateCode 2 = HALF_OPEN but probe limit reached\n * status 1 = admitted (probe token stored)\n * transitioned 1 = OPEN→HALF_OPEN transition happened in this call\n * transitioned 0 = was already HALF_OPEN\n */\nexport const breakerAdmitProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal openMs = tonumber(ARGV[2])\nlocal maxProbes = tonumber(ARGV[3])\nlocal probeLeaseTtl = tonumber(ARGV[4])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'openedAt',\n 'probeCount', 'probeSuccesses')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal openedAt = tonumber(f[3]) or 0\nlocal transitioned = 0\nif state == 'closed' then\n return {0, 0, gen, 0, 0}\nend\nif state == 'open' then\n if now - openedAt < openMs then\n return {0, 1, gen, 0, 0}\n end\n state = 'half-open'\n transitioned = 1\n redis.call('HSET', KEYS[1], 'state', 'half-open', 'probeSuccesses', 0, 'probeCount', 0)\n -- OPEN/HALF_OPEN must never expire; PERSIST removes any prior cleanup TTL.\n redis.call('PERSIST', KEYS[1])\n -- Tokens from the superseded recovery window must not consume slots in this\n -- one; their settle is dropped by the generation check anyway.\n redis.call('DEL', KEYS[2])\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)\nlocal activeProbes = redis.call('ZCARD', KEYS[2])\nif activeProbes >= maxProbes then\n return {0, 2, gen, activeProbes, transitioned}\nend\nredis.call('ZADD', KEYS[2], 'NX', now + probeLeaseTtl, probeToken)\nactiveProbes = activeProbes + 1\nredis.call('HSET', KEYS[1], 'probeCount', activeProbes)\n-- State hash must not expire while recovery probes are in flight.\nredis.call('PERSIST', KEYS[1])\n-- Probes sorted set expires naturally once the last lease elapses.\nredis.call('PEXPIREAT', KEYS[2], now + probeLeaseTtl + 1000)\nreturn {1, 2, gen, activeProbes, transitioned}\n`\n\n/**\n * Record a probe attempt outcome. Removes the probe token and applies the\n * result: failure re-opens; sufficient successes close the breaker.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken the token issued by admitProbe\n * ARGV[2] = outcome \"success\" | \"failure\" | \"ignored\", where\n * \"ignored\" releases the slot without recording\n * an outcome or advancing recovery\n * ARGV[3] = expectedGeneration generation captured at admission time\n * ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close\n * ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)\n * ARGV[6] = windowTtlMs observation retention (TTL floor): the CLOSED\n * hash must outlive the window it governs\n *\n * Returns: {status, stateCode, generation}\n * status 0 = stale (token missing, lease elapsed, or generation mismatch;\n * the result is dropped and any consumed token is released)\n * status 1 = settled, no state transition (still HALF_OPEN; an \"ignored\"\n * outcome only releases the probe slot)\n * status 2 = settled, state transition occurred\n * stateCode 0 = transitioned to CLOSED\n * stateCode 1 = transitioned back to OPEN (probe failure)\n *\n * A token carries its own deadline as its sorted-set score. When the lease has\n * elapsed the slot is already recoverable - admitProbe prunes expired tokens\n * and re-issues the slot - so the result must not count even while the member is\n * still present. The deadline is therefore checked under the same atomic call.\n */\nexport const breakerSettleProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal outcome = ARGV[2]\nlocal expectGen = tonumber(ARGV[3])\nlocal halfOpenSucc = tonumber(ARGV[4])\nlocal openMs = tonumber(ARGV[5])\nlocal windowTtl = tonumber(ARGV[6])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'probeSuccesses', 'probeCount')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal probeSucc = tonumber(f[3]) or 0\nlocal probeCnt = tonumber(f[4]) or 0\nlocal sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\nlocal deadline = redis.call('ZSCORE', KEYS[2], probeToken)\nif not deadline then\n -- Never admitted, already settled, or dropped with its recovery window.\n return {0, sc, gen}\nend\nredis.call('ZREM', KEYS[2], probeToken)\nif tonumber(deadline) <= now then\n -- The lease elapsed while the probe was still running: its slot was\n -- recoverable, so the result cannot be counted. Release the slot it still\n -- occupied so the probe count keeps matching the token set.\n probeCnt = math.max(0, probeCnt - 1)\n redis.call('HSET', KEYS[1], 'probeCount', probeCnt)\n return {0, sc, gen}\nend\nif state ~= 'half-open' or gen ~= expectGen then\n return {0, sc, gen}\nend\nprobeCnt = math.max(0, probeCnt - 1)\nif outcome == 'ignored' then\n -- The result was not recorded (the classifier ignored it), but the slot the\n -- probe held must still be released: otherwise the recovery window stalls\n -- until the probe lease elapses instead of letting the next probe through.\n -- The local breaker releases its slot immediately, and this matches it: no\n -- success counter advances and the state does not transition.\n redis.call('HSET', KEYS[1], 'probeCount', probeCnt)\n -- Still HALF_OPEN: must not expire while recovery is in progress.\n redis.call('PERSIST', KEYS[1])\n return {1, 2, gen}\nend\nif outcome == 'failure' then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- Re-opened: OPEN must not expire (see breakerObserveV1 TTL policy).\n redis.call('PERSIST', KEYS[1])\n -- In-flight probes belong to the dead generation; free their slots so the\n -- next recovery window starts with full capacity.\n redis.call('DEL', KEYS[2])\n return {2, 1, gen}\nend\nprobeSucc = probeSucc + 1\nif probeSucc >= halfOpenSucc then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'probeCount', 0, 'probeSuccesses', 0)\n -- Newly CLOSED: expire for cleanup, but never before the window it governs.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\n redis.call('DEL', KEYS[2])\n return {2, 0, gen}\nend\nredis.call('HSET', KEYS[1], 'probeSuccesses', probeSucc, 'probeCount', probeCnt)\n-- Still HALF_OPEN: must not expire while recovery is in progress.\nredis.call('PERSIST', KEYS[1])\nreturn {1, 2, gen}\n`\n","import type { BulkheadCoordinator } from \"../../core/bulkhead.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport { bulkheadLeaseV1 } from \"./scripts.js\"\nexport function redisCoordinator(\n client: RedisScriptClient,\n options: { namespace: string },\n): BulkheadCoordinator {\n const namespace = options.namespace\n coordinationKey(namespace, \"bulkhead\", \"validate\", \"validate\")\n const coordinator: BulkheadCoordinator = {\n async command(identity, action, token, leaseMs, limit) {\n if (\n ![\"acquire\", \"renew\", \"release\"].includes(action) ||\n typeof token !== \"string\" ||\n !token ||\n token.length > 256\n )\n throw new TypeError(\"Invalid bulkhead lease action or token\")\n if (\n ![leaseMs, limit].every(\n (value) => Number.isSafeInteger(value) && value > 0,\n ) ||\n leaseMs > 86400000\n )\n throw new RangeError(\"Invalid bulkhead lease duration or limit\")\n const key = coordinationKey(\n namespace,\n `bulkhead:${identity.name}`,\n identity.operation,\n identity.scope,\n )\n try {\n const result = await evalScript(\n client,\n bulkheadLeaseV1,\n 1,\n key,\n action,\n token,\n leaseMs,\n limit,\n )\n if (\n !Array.isArray(result) ||\n result.length !== 2 ||\n ![0, 1].includes(result[0]) ||\n !Number.isSafeInteger(result[1]) ||\n result[1] < 0\n )\n throw new Error(\"Invalid bulkhead reply\")\n return { allowed: result[0] === 1, occupancy: result[1] as number }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n return Object.freeze(coordinator)\n}\n","import type {\n AdmitProbeResult,\n BreakerCoordinator,\n BreakerIdentity,\n BreakerState,\n ObserveResult,\n SettleProbeResult,\n} from \"../../core/circuit-breaker.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport {\n breakerAdmitProbeV1,\n breakerObserveV1,\n breakerSettleProbeV1,\n} from \"./scripts.js\"\n\n// ---------------------------------------------------------------------------\n// Key helpers\n// ---------------------------------------------------------------------------\n\nfunction breakerKeys(\n namespace: string,\n identity: BreakerIdentity,\n): [hashKey: string, obsKey: string, probeKey: string] {\n const policy = `breaker:${identity.name}`\n const hash = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"breaker\",\n )\n const obs = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"observations\",\n )\n const prob = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"probes\",\n )\n return [hash, obs, prob]\n}\n\n// ---------------------------------------------------------------------------\n// Reply validation helpers\n// ---------------------------------------------------------------------------\n\nfunction assertArray(reply: unknown, minLen: number, label: string): unknown[] {\n if (\n !Array.isArray(reply) ||\n reply.length < minLen ||\n reply.some((v) => typeof v !== \"number\" || !Number.isSafeInteger(v))\n ) {\n throw new Error(\n `Invalid ${label} reply from Redis: ${JSON.stringify(reply)}`,\n )\n }\n return reply as unknown[]\n}\n\nconst STATE_CODES: BreakerState[] = [\"closed\", \"open\", \"half-open\"]\n\nfunction decodeState(code: number): BreakerState {\n const s = STATE_CODES[code]\n if (!s) throw new Error(`Unknown breaker state code: ${code}`)\n return s\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Policy-specific Redis coordinator for `circuitBreaker.distributed()`.\n *\n * The caller owns `client` and must connect/disconnect it independently.\n * `namespace` is prepended to every key; use a per-service, per-environment\n * value to avoid cross-deployment state collisions.\n */\nexport function redisCircuitBreakerCoordinator(\n client: RedisScriptClient,\n options: { readonly namespace: string },\n): BreakerCoordinator {\n const { namespace } = options\n // Validate namespace eagerly (coordinationKey throws on bad input).\n coordinationKey(\n namespace,\n \"breaker:validate\",\n \"validate\",\n \"validate\",\n \"breaker\",\n )\n\n const coordinator: BreakerCoordinator = {\n async readState(identity) {\n const [hashKey] = breakerKeys(namespace, identity)\n try {\n const fields = await client.hmget(hashKey, \"state\", \"generation\")\n const rawState = fields[0]\n if (!rawState) return null\n const state = rawState as BreakerState\n if (!STATE_CODES.includes(state))\n throw new Error(`Unknown breaker state: ${rawState}`)\n const generation = parseInt(fields[1] ?? \"0\", 10)\n return { state, generation }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async observe(identity, params) {\n const [hashKey, obsKey] = breakerKeys(namespace, identity)\n const {\n generation,\n outcome,\n uuid,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerObserveV1,\n 2,\n hashKey,\n obsKey,\n outcome,\n generation,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n uuid,\n )\n const r = assertArray(reply, 5, \"breakerObserveV1\") as number[]\n const [status, , newGen, windowTotal, windowFailures] = r\n if (status === 0) {\n return {\n type: \"stale\",\n currentGeneration: newGen,\n } satisfies ObserveResult\n }\n if (status === 2) {\n return {\n type: \"opened\",\n newGeneration: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n }\n return {\n type: \"observed\",\n generation: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async admitProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const { probeToken, openMs, halfOpenProbes, probeLeaseTtlMs } = params\n try {\n const reply = await evalScript(\n client,\n breakerAdmitProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n openMs,\n halfOpenProbes,\n probeLeaseTtlMs,\n )\n const r = assertArray(reply, 5, \"breakerAdmitProbeV1\") as number[]\n const [status, stateCode, gen, probeCount, transitioned] = r\n if (status === 0) {\n const reason =\n stateCode === 0\n ? \"closed\"\n : stateCode === 1\n ? \"open\"\n : \"probe-limit\"\n return {\n type: \"rejected\",\n reason,\n generation: gen,\n } satisfies AdmitProbeResult\n }\n return {\n type: \"admitted\",\n generation: gen,\n probeCount,\n stateChanged: transitioned === 1,\n } satisfies AdmitProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async settleProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const {\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n windowTtlMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerSettleProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n // Floor for the CLOSED cleanup TTL; older callers that do not pass it\n // keep the historical openMs x 2 behaviour.\n windowTtlMs ?? openMs * 2,\n )\n const r = assertArray(reply, 3, \"breakerSettleProbeV1\") as number[]\n const [status, stateCode, newGen] = r\n if (status === 0) {\n return {\n type: \"stale\",\n generation: newGen,\n } satisfies SettleProbeResult\n }\n const state = decodeState(stateCode)\n if (status === 2) {\n return {\n type: \"transitioned\",\n newState: state,\n newGeneration: newGen,\n previousState: \"half-open\",\n } satisfies SettleProbeResult\n }\n return {\n type: \"settled\",\n state,\n generation: newGen,\n } satisfies SettleProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n\n return Object.freeze(coordinator)\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gkoos/caracal",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Scoped distributed resilience for asynchronous operations.",
5
5
  "keywords": [
6
6
  "resilience",
@@ -31,7 +31,7 @@
31
31
  "type": "module",
32
32
  "sideEffects": false,
33
33
  "engines": {
34
- "node": ">=20"
34
+ "node": ">=20.3.0"
35
35
  },
36
36
  "exports": {
37
37
  ".": {
@@ -59,6 +59,7 @@
59
59
  "files": [
60
60
  "dist",
61
61
  "src",
62
+ "test/harness",
62
63
  "README.md",
63
64
  "CHANGELOG.md",
64
65
  "LICENSE"
@@ -104,16 +105,16 @@
104
105
  "@types/node": "^22.0.0",
105
106
  "@types/pg": "^8.23.1",
106
107
  "fast-check": "^4.0.0",
107
- "ioredis": "^5.11.1",
108
+ "ioredis": "^6.0.0",
108
109
  "pg": "^8.23.0",
109
110
  "rimraf": "^6.0.0",
110
111
  "tsup": "^8.0.0",
111
112
  "tsx": "^4.23.13",
112
113
  "typescript": "^5.0.0",
113
- "vitest": "^3.0.0"
114
+ "vitest": "^4.1.11"
114
115
  },
115
116
  "peerDependencies": {
116
- "ioredis": "^5.0.0",
117
+ "ioredis": "^5.0.0 || ^6.0.0",
117
118
  "pg": ">=8.11 <9"
118
119
  },
119
120
  "peerDependenciesMeta": {
@@ -123,5 +124,8 @@
123
124
  "ioredis": {
124
125
  "optional": true
125
126
  }
127
+ },
128
+ "overrides": {
129
+ "esbuild": "^0.28.1"
126
130
  }
127
131
  }
@@ -50,7 +50,10 @@ export const bulkheadLeaseV1 = `local function transition()\n${leaseV1}\nend\nlo
50
50
  * ARGV[2] = expectedGeneration integer
51
51
  * ARGV[3] = windowTtlMs observation retention window in ms
52
52
  * ARGV[4] = minimumThroughput min observations before opening
53
- * ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5)
53
+ * ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5).
54
+ * The policy rejects thresholds that would round
55
+ * to 0 or to 1000 before they reach this script
56
+ * (see assertResolvableThreshold in core).
54
57
  * ARGV[6] = windowSize max observations retained by count
55
58
  * ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2
56
59
  * ARGV[8] = uuid unique string for member deduplication
@@ -233,7 +236,9 @@ return {1, 2, gen, activeProbes, transitioned}
233
236
  * KEYS[2] = probe tokens sorted set (:probes)
234
237
  *
235
238
  * ARGV[1] = probeToken the token issued by admitProbe
236
- * ARGV[2] = outcome "success" | "failure"
239
+ * ARGV[2] = outcome "success" | "failure" | "ignored", where
240
+ * "ignored" releases the slot without recording
241
+ * an outcome or advancing recovery
237
242
  * ARGV[3] = expectedGeneration generation captured at admission time
238
243
  * ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close
239
244
  * ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)
@@ -243,7 +248,8 @@ return {1, 2, gen, activeProbes, transitioned}
243
248
  * Returns: {status, stateCode, generation}
244
249
  * status 0 = stale (token missing, lease elapsed, or generation mismatch;
245
250
  * the result is dropped and any consumed token is released)
246
- * status 1 = settled, no state transition (still HALF_OPEN)
251
+ * status 1 = settled, no state transition (still HALF_OPEN; an "ignored"
252
+ * outcome only releases the probe slot)
247
253
  * status 2 = settled, state transition occurred
248
254
  * stateCode 0 = transitioned to CLOSED
249
255
  * stateCode 1 = transitioned back to OPEN (probe failure)
@@ -286,6 +292,17 @@ if state ~= 'half-open' or gen ~= expectGen then
286
292
  return {0, sc, gen}
287
293
  end
288
294
  probeCnt = math.max(0, probeCnt - 1)
295
+ if outcome == 'ignored' then
296
+ -- The result was not recorded (the classifier ignored it), but the slot the
297
+ -- probe held must still be released: otherwise the recovery window stalls
298
+ -- until the probe lease elapses instead of letting the next probe through.
299
+ -- The local breaker releases its slot immediately, and this matches it: no
300
+ -- success counter advances and the state does not transition.
301
+ redis.call('HSET', KEYS[1], 'probeCount', probeCnt)
302
+ -- Still HALF_OPEN: must not expire while recovery is in progress.
303
+ redis.call('PERSIST', KEYS[1])
304
+ return {1, 2, gen}
305
+ end
289
306
  if outcome == 'failure' then
290
307
  gen = gen + 1
291
308
  redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,
@@ -148,8 +148,11 @@ function local(options: LocalBulkheadOptions): Policy & {
148
148
  return await next(context)
149
149
  } finally {
150
150
  occupancy--
151
- waiting[0]?.()
151
+ // Report the released permit before handing it to the queued successor:
152
+ // granting first would make this event's occupancy already include the
153
+ // next admission, so the released/admitted pair would look inverted.
152
154
  event(context, "local", name, "process", "released", occupancy)
155
+ waiting[0]?.()
153
156
  }
154
157
  },
155
158
  })
@@ -108,10 +108,35 @@ const DEFAULT_HALF_OPEN_SUCCESSES = 1
108
108
  const DEFAULT_HALF_OPEN_PROBES = 1
109
109
  const DEFAULT_WINDOW_SIZE = 100
110
110
 
111
+ // The distributed coordinator compares the failure threshold as an integer
112
+ // numerator of thousandths (`wFail * 1000 >= numerator * wTotal`). A threshold
113
+ // that rounds to 0 makes that comparison unconditionally true, so the breaker
114
+ // opens on a success-only window and re-opens after every recovery; a threshold
115
+ // that rounds to the full scale requires every observation to fail, so the
116
+ // breaker effectively never opens. Both are rejected instead of silently
117
+ // reinterpreted, and the local breaker enforces the same bounds so one policy
118
+ // config works with either coordination.
119
+ const FAILURE_THRESHOLD_SCALE = 1000
120
+
111
121
  // ---------------------------------------------------------------------------
112
122
  // Validation
113
123
  // ---------------------------------------------------------------------------
114
124
 
125
+ /**
126
+ * Rejects a failureThreshold the thousandths comparison cannot represent.
127
+ *
128
+ * Accepts `0.0005 <= failureThreshold < 0.9995`. Anything else resolves to a
129
+ * numerator of 0 or of the full scale, which changes what the threshold means
130
+ * rather than how precisely it is expressed.
131
+ */
132
+ function assertResolvableThreshold(failureThreshold: number): void {
133
+ const numerator = Math.round(failureThreshold * FAILURE_THRESHOLD_SCALE)
134
+ if (numerator < 1 || numerator >= FAILURE_THRESHOLD_SCALE)
135
+ throw new RangeError(
136
+ `failureThreshold must be at least 0.0005 and below 0.9995 (thresholds are resolved to thousandths); got ${failureThreshold}`,
137
+ )
138
+ }
139
+
115
140
  function validate(opts: LocalBreakerOptions): void {
116
141
  if (!opts.name.trim())
117
142
  throw new RangeError("Circuit breaker name must not be empty")
@@ -127,6 +152,7 @@ function validate(opts: LocalBreakerOptions): void {
127
152
  failureThreshold >= 1
128
153
  )
129
154
  throw new RangeError("failureThreshold must be a number in (0, 1)")
155
+ assertResolvableThreshold(failureThreshold)
130
156
 
131
157
  const { openMs = DEFAULT_OPEN_MS } = opts
132
158
  if (!Number.isInteger(openMs) || openMs < 1)
@@ -519,7 +545,12 @@ export interface BreakerCoordinator {
519
545
  identity: BreakerIdentity,
520
546
  params: {
521
547
  readonly probeToken: string
522
- readonly outcome: "success" | "failure"
548
+ /**
549
+ * `"success"` and `"failure"` record an outcome; `"ignored"` releases the
550
+ * probe's slot without recording anything, which is what the policy sends
551
+ * when its classifier ignores the result.
552
+ */
553
+ readonly outcome: "success" | "failure" | "ignored"
523
554
  readonly generation: number
524
555
  readonly halfOpenSuccesses: number
525
556
  readonly openMs: number
@@ -648,6 +679,7 @@ function validateDistributed(opts: DistributedBreakerOptions): void {
648
679
  failureThreshold >= 1
649
680
  )
650
681
  throw new RangeError("failureThreshold must be a number in (0, 1)")
682
+ assertResolvableThreshold(failureThreshold)
651
683
 
652
684
  const { openMs = DEFAULT_DIST_OPEN_MS } = opts
653
685
  if (!Number.isInteger(openMs) || openMs < 1)
@@ -701,7 +733,9 @@ function distributed(
701
733
  options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT
702
734
  const failureThreshold =
703
735
  options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD
704
- const failureThresholdNumerator = Math.round(failureThreshold * 1000)
736
+ const failureThresholdNumerator = Math.round(
737
+ failureThreshold * FAILURE_THRESHOLD_SCALE,
738
+ )
705
739
  const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE
706
740
  const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS
707
741
  const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 60_000)
@@ -930,11 +964,18 @@ function distributed(
930
964
  ? { status: "success", value }
931
965
  : { status: "failure", error: thrownError },
932
966
  )
933
- if (breakerOutcome !== "ignored") {
967
+ // An ignored result is never recorded. A probe still has to settle
968
+ // though: releasing the slot it holds is what lets the next probe
969
+ // through, instead of stalling the recovery window until the probe lease
970
+ // elapses. The local breaker frees its slot immediately, and a release
971
+ // records nothing and does not advance recovery.
972
+ if (breakerOutcome !== "ignored" || admission.kind === "probe") {
934
973
  const outcomeStr =
935
974
  breakerOutcome === "success"
936
975
  ? ("success" as const)
937
976
  : ("failure" as const)
977
+ const settleOutcome =
978
+ breakerOutcome === "ignored" ? ("ignored" as const) : outcomeStr
938
979
 
939
980
  if (admission.kind === "closed") {
940
981
  try {
@@ -998,49 +1039,53 @@ function distributed(
998
1039
  try {
999
1040
  const result = await coordinator.settleProbe(identity, {
1000
1041
  probeToken: admission.probeToken,
1001
- outcome: outcomeStr,
1042
+ outcome: settleOutcome,
1002
1043
  generation: admission.generation,
1003
1044
  halfOpenSuccesses,
1004
1045
  openMs,
1005
1046
  windowTtlMs,
1006
1047
  })
1007
- if (result.type === "stale") {
1008
- emitRuntimeEvent(context, {
1009
- type: "breaker.observation-stale",
1010
- coordination: "distributed",
1011
- policyName: name,
1012
- scope,
1013
- attemptGeneration: admission.generation,
1014
- currentGeneration: result.generation,
1015
- })
1016
- } else {
1017
- emitRuntimeEvent(context, {
1018
- type: "breaker.observation",
1019
- coordination: "distributed",
1020
- policyName: name,
1021
- scope,
1022
- outcome: outcomeStr,
1023
- generation: admission.generation,
1024
- })
1025
- if (result.type === "transitioned") {
1026
- if (result.newState === "closed") {
1027
- lastKnownState.forget(identity.operation, scope)
1028
- } else {
1029
- lastKnownState.remember(
1030
- identity.operation,
1031
- scope,
1032
- result.newState,
1033
- )
1034
- }
1048
+ // A release reports nothing: it is not an observation, so there is
1049
+ // no breaker.observation event and no transition to announce.
1050
+ if (breakerOutcome !== "ignored") {
1051
+ if (result.type === "stale") {
1035
1052
  emitRuntimeEvent(context, {
1036
- type: "breaker.state-changed",
1053
+ type: "breaker.observation-stale",
1037
1054
  coordination: "distributed",
1038
1055
  policyName: name,
1039
1056
  scope,
1040
- state: result.newState,
1041
- previousState: "half-open",
1042
- generation: result.newGeneration,
1057
+ attemptGeneration: admission.generation,
1058
+ currentGeneration: result.generation,
1059
+ })
1060
+ } else {
1061
+ emitRuntimeEvent(context, {
1062
+ type: "breaker.observation",
1063
+ coordination: "distributed",
1064
+ policyName: name,
1065
+ scope,
1066
+ outcome: outcomeStr,
1067
+ generation: admission.generation,
1043
1068
  })
1069
+ if (result.type === "transitioned") {
1070
+ if (result.newState === "closed") {
1071
+ lastKnownState.forget(identity.operation, scope)
1072
+ } else {
1073
+ lastKnownState.remember(
1074
+ identity.operation,
1075
+ scope,
1076
+ result.newState,
1077
+ )
1078
+ }
1079
+ emitRuntimeEvent(context, {
1080
+ type: "breaker.state-changed",
1081
+ coordination: "distributed",
1082
+ policyName: name,
1083
+ scope,
1084
+ state: result.newState,
1085
+ previousState: "half-open",
1086
+ generation: result.newGeneration,
1087
+ })
1088
+ }
1044
1089
  }
1045
1090
  }
1046
1091
  } catch (settleError) {