actly 1.2.0 → 1.3.0

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.
Files changed (151) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +326 -218
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +406 -220
  6. package/dist/core/act.d.ts +6 -84
  7. package/dist/core/act.d.ts.map +1 -1
  8. package/dist/core/act.js +405 -222
  9. package/dist/core/executor.cjs +0 -27
  10. package/dist/core/executor.d.ts +0 -41
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +0 -28
  13. package/dist/core/health.cjs +146 -17
  14. package/dist/core/health.d.ts +20 -5
  15. package/dist/core/health.d.ts.map +1 -1
  16. package/dist/core/health.js +140 -18
  17. package/dist/core/shutdown.cjs +19 -8
  18. package/dist/core/shutdown.d.ts +1 -4
  19. package/dist/core/shutdown.d.ts.map +1 -1
  20. package/dist/core/shutdown.js +18 -9
  21. package/dist/core/tenant.cjs +61 -19
  22. package/dist/core/tenant.d.ts +5 -10
  23. package/dist/core/tenant.d.ts.map +1 -1
  24. package/dist/core/tenant.js +61 -20
  25. package/dist/errors.cjs +70 -103
  26. package/dist/errors.d.ts +33 -90
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +66 -103
  29. package/dist/index.cjs +25 -22
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -15
  33. package/dist/observability.cjs +0 -39
  34. package/dist/observability.d.ts +19 -78
  35. package/dist/observability.d.ts.map +1 -1
  36. package/dist/observability.js +0 -40
  37. package/dist/policies/bulkhead.cjs +44 -4
  38. package/dist/policies/bulkhead.d.ts +0 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -1
  40. package/dist/policies/bulkhead.js +44 -5
  41. package/dist/policies/cache.cjs +22 -142
  42. package/dist/policies/cache.d.ts +0 -46
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +22 -143
  45. package/dist/policies/circuitBreaker.cjs +125 -31
  46. package/dist/policies/circuitBreaker.d.ts +0 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -1
  48. package/dist/policies/circuitBreaker.js +125 -32
  49. package/dist/policies/dedupe.cjs +19 -66
  50. package/dist/policies/dedupe.d.ts +0 -23
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +19 -67
  53. package/dist/policies/noop.cjs +6 -0
  54. package/dist/policies/noop.d.ts +3 -0
  55. package/dist/policies/noop.d.ts.map +1 -0
  56. package/dist/policies/noop.js +3 -0
  57. package/dist/policies/rateLimit.cjs +15 -5
  58. package/dist/policies/rateLimit.d.ts +0 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -1
  60. package/dist/policies/rateLimit.js +15 -6
  61. package/dist/policies/retry.cjs +100 -65
  62. package/dist/policies/retry.d.ts +0 -27
  63. package/dist/policies/retry.d.ts.map +1 -1
  64. package/dist/policies/retry.js +100 -66
  65. package/dist/policies/timeout.cjs +32 -60
  66. package/dist/policies/timeout.d.ts +0 -13
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +32 -71
  69. package/dist/stores/base.cjs +0 -10
  70. package/dist/stores/base.d.ts +4 -67
  71. package/dist/stores/base.d.ts.map +1 -1
  72. package/dist/stores/base.js +0 -11
  73. package/dist/stores/memory.cjs +75 -88
  74. package/dist/stores/memory.d.ts +7 -74
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +75 -89
  77. package/dist/testing/index.cjs +56 -0
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +52 -0
  81. package/dist/types/index.cjs +0 -1
  82. package/dist/types/index.d.ts +17 -307
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +14 -111
  86. package/dist/utils/abort.d.ts +3 -74
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +14 -112
  89. package/dist/utils/abortPool.cjs +1 -2
  90. package/dist/utils/abortPool.d.ts +0 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -1
  92. package/dist/utils/abortPool.js +1 -3
  93. package/dist/utils/backoff.cjs +3 -21
  94. package/dist/utils/backoff.d.ts +0 -11
  95. package/dist/utils/backoff.d.ts.map +1 -1
  96. package/dist/utils/backoff.js +3 -22
  97. package/dist/utils/decorator.cjs +44 -0
  98. package/dist/utils/decorator.d.ts +3 -0
  99. package/dist/utils/decorator.d.ts.map +1 -0
  100. package/dist/utils/decorator.js +41 -0
  101. package/dist/utils/key.cjs +13 -45
  102. package/dist/utils/key.d.ts +0 -27
  103. package/dist/utils/key.d.ts.map +1 -1
  104. package/dist/utils/key.js +13 -46
  105. package/dist/utils/limits.cjs +9 -27
  106. package/dist/utils/limits.d.ts +9 -27
  107. package/dist/utils/limits.d.ts.map +1 -1
  108. package/dist/utils/limits.js +9 -28
  109. package/dist/utils/safeCall.cjs +28 -0
  110. package/dist/utils/safeCall.d.ts +2 -0
  111. package/dist/utils/safeCall.d.ts.map +1 -0
  112. package/dist/utils/safeCall.js +25 -0
  113. package/dist/utils/sanitize.cjs +22 -1
  114. package/dist/utils/sanitize.d.ts +0 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -1
  116. package/dist/utils/sanitize.js +22 -2
  117. package/dist/utils/validate.cjs +81 -18
  118. package/dist/utils/validate.d.ts +6 -19
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +81 -19
  121. package/package.json +38 -4
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/core/health.js.map +0 -1
  125. package/dist/core/shutdown.js.map +0 -1
  126. package/dist/core/tenant.js.map +0 -1
  127. package/dist/errors.js.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/observability.js.map +0 -1
  130. package/dist/policies/bulkhead.js.map +0 -1
  131. package/dist/policies/cache.js.map +0 -1
  132. package/dist/policies/circuitBreaker.js.map +0 -1
  133. package/dist/policies/dedupe.js.map +0 -1
  134. package/dist/policies/rateLimit.js.map +0 -1
  135. package/dist/policies/retry.js.map +0 -1
  136. package/dist/policies/timeout.js.map +0 -1
  137. package/dist/state/store.cjs +0 -14
  138. package/dist/state/store.d.ts +0 -11
  139. package/dist/state/store.d.ts.map +0 -1
  140. package/dist/state/store.js +0 -11
  141. package/dist/state/store.js.map +0 -1
  142. package/dist/stores/base.js.map +0 -1
  143. package/dist/stores/memory.js.map +0 -1
  144. package/dist/types/index.js.map +0 -1
  145. package/dist/utils/abort.js.map +0 -1
  146. package/dist/utils/abortPool.js.map +0 -1
  147. package/dist/utils/backoff.js.map +0 -1
  148. package/dist/utils/key.js.map +0 -1
  149. package/dist/utils/limits.js.map +0 -1
  150. package/dist/utils/sanitize.js.map +0 -1
  151. package/dist/utils/validate.js.map +0 -1
@@ -5,27 +5,88 @@ const executor_js_1 = require("../core/executor.js");
5
5
  const errors_js_1 = require("../errors.js");
6
6
  const abort_js_1 = require("../utils/abort.js");
7
7
  const NS = 'cb:';
8
- function getState(store, key) {
9
- return store.get(NS + key) ?? { failures: 0, lastFailureTime: 0, isOpen: false, openedAt: 0, halfOpen: false };
8
+ function getState(store, key, strategy) {
9
+ const existing = store.get(NS + key);
10
+ if (existing)
11
+ return existing;
12
+ if (strategy === 'count') {
13
+ return {
14
+ strategy: 'count',
15
+ outcomes: [],
16
+ index: 0,
17
+ failures: 0,
18
+ filled: 0,
19
+ isOpen: false,
20
+ openedAt: 0,
21
+ halfOpen: false,
22
+ lastFailureTime: 0,
23
+ };
24
+ }
25
+ return {
26
+ strategy: 'consecutive',
27
+ failures: 0,
28
+ lastFailureTime: 0,
29
+ isOpen: false,
30
+ openedAt: 0,
31
+ halfOpen: false,
32
+ };
10
33
  }
11
34
  function setState(store, key, state) {
12
35
  store.set(NS + key, state);
13
36
  }
37
+ function recordCountOutcome(state, success, windowSize) {
38
+ if (state.outcomes.length < windowSize) {
39
+ state.outcomes.push(success);
40
+ state.filled = state.outcomes.length;
41
+ if (!success)
42
+ state.failures++;
43
+ state.index = (state.index + 1) % windowSize;
44
+ return;
45
+ }
46
+ if (state.outcomes[state.index] === false)
47
+ state.failures--;
48
+ state.outcomes[state.index] = success;
49
+ if (!success)
50
+ state.failures++;
51
+ state.index = (state.index + 1) % windowSize;
52
+ state.filled = windowSize;
53
+ }
54
+ function countFailureRate(state) {
55
+ if (state.filled === 0)
56
+ return 0;
57
+ return state.failures / state.filled;
58
+ }
59
+ function resetCountState(state) {
60
+ state.outcomes = [];
61
+ state.index = 0;
62
+ state.failures = 0;
63
+ state.filled = 0;
64
+ state.isOpen = false;
65
+ state.openedAt = 0;
66
+ state.halfOpen = false;
67
+ state.lastFailureTime = 0;
68
+ }
14
69
  function circuitBreakerPolicy(opts) {
15
70
  const threshold = Math.max(1, Math.floor(opts.threshold));
16
71
  const cooldownMs = opts.cooldownMs;
17
72
  const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
73
+ const strategy = opts.strategy ?? 'consecutive';
74
+ const countSize = strategy === 'count' ? Math.max(1, Math.floor(opts.countSize ?? 100)) : 0;
75
+ const countThreshold = strategy === 'count'
76
+ ? Math.min(1, Math.max(0, opts.countThreshold ?? 0.5))
77
+ : 0;
78
+ const countMinimumCalls = strategy === 'count'
79
+ ? Math.max(1, Math.floor(opts.countMinimumCalls ?? countSize))
80
+ : 0;
18
81
  const applier = (fn, ctx) => {
19
82
  const syncCtx = ctx;
20
83
  return async (signal) => {
21
84
  const key = syncCtx.key;
22
85
  const now = Date.now();
23
- // Read state synchronously and make decisions atomically
24
- const state = getState(syncCtx.store, key);
86
+ const state = getState(syncCtx.store, key, strategy);
25
87
  if (state.isOpen) {
26
88
  const elapsed = now - state.openedAt;
27
89
  if (elapsed >= cooldownMs) {
28
- // Transition to half-open: allow only ONE probe call
29
90
  state.isOpen = false;
30
91
  state.halfOpen = true;
31
92
  setState(syncCtx.store, key, state);
@@ -35,45 +96,78 @@ function circuitBreakerPolicy(opts) {
35
96
  }
36
97
  }
37
98
  else if (state.halfOpen) {
38
- // Another call is already probing — block this one
39
99
  throw new errors_js_1.CircuitBreakerOpenError(key, 0);
40
100
  }
41
- if (now - state.lastFailureTime > resetTimeoutMs && state.failures > 0) {
42
- state.failures = 0;
43
- setState(syncCtx.store, key, state);
101
+ if (now - state.lastFailureTime > resetTimeoutMs && state.lastFailureTime > 0) {
102
+ if (!state.halfOpen) {
103
+ if (state.strategy === 'count') {
104
+ resetCountState(state);
105
+ }
106
+ else {
107
+ state.failures = 0;
108
+ state.isOpen = false;
109
+ state.openedAt = 0;
110
+ state.halfOpen = false;
111
+ }
112
+ setState(syncCtx.store, key, state);
113
+ }
44
114
  }
45
115
  try {
46
116
  const result = await fn(signal);
47
- // Re-read state (it may have changed during await) and update atomically
48
- const updated = getState(syncCtx.store, key);
49
- updated.failures = 0;
50
- updated.isOpen = false;
51
- updated.halfOpen = false;
52
- setState(syncCtx.store, key, updated);
117
+ const updated = getState(syncCtx.store, key, strategy);
118
+ const wasHalfOpen = updated.halfOpen;
119
+ if (wasHalfOpen && updated.strategy === 'count') {
120
+ resetCountState(updated);
121
+ }
122
+ else if (updated.strategy === 'count') {
123
+ recordCountOutcome(updated, true, countSize);
124
+ }
125
+ else {
126
+ updated.failures = 0;
127
+ }
128
+ if (wasHalfOpen) {
129
+ updated.isOpen = false;
130
+ updated.halfOpen = false;
131
+ }
132
+ const isIdle = updated.failures === 0 && !updated.isOpen && !updated.halfOpen;
133
+ if (isIdle) {
134
+ syncCtx.store.delete(NS + key);
135
+ }
136
+ else {
137
+ setState(syncCtx.store, key, updated);
138
+ }
53
139
  return result;
54
140
  }
55
141
  catch (err) {
56
- // Signal aborts are not downstream failures don't count them
57
- if ((0, abort_js_1.isAbortError)(err) || (signal.aborted && err === signal.reason)) {
58
- const updated = getState(syncCtx.store, key);
59
- if (updated.halfOpen) {
60
- // Abort during half-open: probe didn't succeed or fail —
61
- // downstream health is still unknown. Go back to OPEN with
62
- // fresh cooldown so next call waits before probing again.
63
- updated.isOpen = true;
64
- updated.openedAt = Date.now();
65
- updated.halfOpen = false;
142
+ if (signal.aborted && ((0, abort_js_1.isAbortError)(err) || err === signal.reason)) {
143
+ const updated = getState(syncCtx.store, key, strategy);
144
+ if (updated.failures === 0 && !updated.isOpen && !updated.halfOpen) {
145
+ syncCtx.store.delete(NS + key);
146
+ }
147
+ else {
148
+ setState(syncCtx.store, key, updated);
66
149
  }
67
- setState(syncCtx.store, key, updated);
68
150
  throw err;
69
151
  }
70
- const updated = getState(syncCtx.store, key);
71
- updated.failures++;
152
+ const updated = getState(syncCtx.store, key, strategy);
153
+ const wasHalfOpen = updated.halfOpen;
72
154
  updated.lastFailureTime = Date.now();
73
155
  updated.halfOpen = false;
74
- if (updated.failures >= threshold) {
75
- updated.isOpen = true;
76
- updated.openedAt = Date.now();
156
+ if (updated.strategy === 'count') {
157
+ recordCountOutcome(updated, false, countSize);
158
+ const rate = countFailureRate(updated);
159
+ const enoughCalls = updated.filled >= countMinimumCalls;
160
+ if (wasHalfOpen || (enoughCalls && rate > countThreshold)) {
161
+ updated.isOpen = true;
162
+ updated.openedAt = Date.now();
163
+ }
164
+ }
165
+ else {
166
+ updated.failures++;
167
+ if (updated.failures >= threshold || wasHalfOpen) {
168
+ updated.isOpen = true;
169
+ updated.openedAt = Date.now();
170
+ }
77
171
  }
78
172
  setState(syncCtx.store, key, updated);
79
173
  throw err;
File without changes
@@ -1 +1 @@
1
- {"version":3,"file":"circuitBreaker.d.ts","sourceRoot":"","sources":["../../src/policies/circuitBreaker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,qBAAqB,EAAE,MAAM,mBAAmB,CAAA;AAwBnG,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,IAAI,EAAE,qBAAqB,GAAG,aAAa,CAAC,CAAC,CAAC,CA2ErF"}
1
+ {"version":3,"file":"circuitBreaker.d.ts","sourceRoot":"","sources":["../../src/policies/circuitBreaker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,qBAAqB,EAAE,MAAM,mBAAmB,CAAA;AAoGnG,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,IAAI,EAAE,qBAAqB,GAAG,aAAa,CAAC,CAAC,CAAC,CAiIrF"}
@@ -2,27 +2,88 @@ import { REQUIRES_SYNC_STORE } from '../core/executor.js';
2
2
  import { CircuitBreakerOpenError } from '../errors.js';
3
3
  import { isAbortError } from '../utils/abort.js';
4
4
  const NS = 'cb:';
5
- function getState(store, key) {
6
- return store.get(NS + key) ?? { failures: 0, lastFailureTime: 0, isOpen: false, openedAt: 0, halfOpen: false };
5
+ function getState(store, key, strategy) {
6
+ const existing = store.get(NS + key);
7
+ if (existing)
8
+ return existing;
9
+ if (strategy === 'count') {
10
+ return {
11
+ strategy: 'count',
12
+ outcomes: [],
13
+ index: 0,
14
+ failures: 0,
15
+ filled: 0,
16
+ isOpen: false,
17
+ openedAt: 0,
18
+ halfOpen: false,
19
+ lastFailureTime: 0,
20
+ };
21
+ }
22
+ return {
23
+ strategy: 'consecutive',
24
+ failures: 0,
25
+ lastFailureTime: 0,
26
+ isOpen: false,
27
+ openedAt: 0,
28
+ halfOpen: false,
29
+ };
7
30
  }
8
31
  function setState(store, key, state) {
9
32
  store.set(NS + key, state);
10
33
  }
34
+ function recordCountOutcome(state, success, windowSize) {
35
+ if (state.outcomes.length < windowSize) {
36
+ state.outcomes.push(success);
37
+ state.filled = state.outcomes.length;
38
+ if (!success)
39
+ state.failures++;
40
+ state.index = (state.index + 1) % windowSize;
41
+ return;
42
+ }
43
+ if (state.outcomes[state.index] === false)
44
+ state.failures--;
45
+ state.outcomes[state.index] = success;
46
+ if (!success)
47
+ state.failures++;
48
+ state.index = (state.index + 1) % windowSize;
49
+ state.filled = windowSize;
50
+ }
51
+ function countFailureRate(state) {
52
+ if (state.filled === 0)
53
+ return 0;
54
+ return state.failures / state.filled;
55
+ }
56
+ function resetCountState(state) {
57
+ state.outcomes = [];
58
+ state.index = 0;
59
+ state.failures = 0;
60
+ state.filled = 0;
61
+ state.isOpen = false;
62
+ state.openedAt = 0;
63
+ state.halfOpen = false;
64
+ state.lastFailureTime = 0;
65
+ }
11
66
  export function circuitBreakerPolicy(opts) {
12
67
  const threshold = Math.max(1, Math.floor(opts.threshold));
13
68
  const cooldownMs = opts.cooldownMs;
14
69
  const resetTimeoutMs = opts.resetTimeoutMs ?? Number.POSITIVE_INFINITY;
70
+ const strategy = opts.strategy ?? 'consecutive';
71
+ const countSize = strategy === 'count' ? Math.max(1, Math.floor(opts.countSize ?? 100)) : 0;
72
+ const countThreshold = strategy === 'count'
73
+ ? Math.min(1, Math.max(0, opts.countThreshold ?? 0.5))
74
+ : 0;
75
+ const countMinimumCalls = strategy === 'count'
76
+ ? Math.max(1, Math.floor(opts.countMinimumCalls ?? countSize))
77
+ : 0;
15
78
  const applier = (fn, ctx) => {
16
79
  const syncCtx = ctx;
17
80
  return async (signal) => {
18
81
  const key = syncCtx.key;
19
82
  const now = Date.now();
20
- // Read state synchronously and make decisions atomically
21
- const state = getState(syncCtx.store, key);
83
+ const state = getState(syncCtx.store, key, strategy);
22
84
  if (state.isOpen) {
23
85
  const elapsed = now - state.openedAt;
24
86
  if (elapsed >= cooldownMs) {
25
- // Transition to half-open: allow only ONE probe call
26
87
  state.isOpen = false;
27
88
  state.halfOpen = true;
28
89
  setState(syncCtx.store, key, state);
@@ -32,45 +93,78 @@ export function circuitBreakerPolicy(opts) {
32
93
  }
33
94
  }
34
95
  else if (state.halfOpen) {
35
- // Another call is already probing — block this one
36
96
  throw new CircuitBreakerOpenError(key, 0);
37
97
  }
38
- if (now - state.lastFailureTime > resetTimeoutMs && state.failures > 0) {
39
- state.failures = 0;
40
- setState(syncCtx.store, key, state);
98
+ if (now - state.lastFailureTime > resetTimeoutMs && state.lastFailureTime > 0) {
99
+ if (!state.halfOpen) {
100
+ if (state.strategy === 'count') {
101
+ resetCountState(state);
102
+ }
103
+ else {
104
+ state.failures = 0;
105
+ state.isOpen = false;
106
+ state.openedAt = 0;
107
+ state.halfOpen = false;
108
+ }
109
+ setState(syncCtx.store, key, state);
110
+ }
41
111
  }
42
112
  try {
43
113
  const result = await fn(signal);
44
- // Re-read state (it may have changed during await) and update atomically
45
- const updated = getState(syncCtx.store, key);
46
- updated.failures = 0;
47
- updated.isOpen = false;
48
- updated.halfOpen = false;
49
- setState(syncCtx.store, key, updated);
114
+ const updated = getState(syncCtx.store, key, strategy);
115
+ const wasHalfOpen = updated.halfOpen;
116
+ if (wasHalfOpen && updated.strategy === 'count') {
117
+ resetCountState(updated);
118
+ }
119
+ else if (updated.strategy === 'count') {
120
+ recordCountOutcome(updated, true, countSize);
121
+ }
122
+ else {
123
+ updated.failures = 0;
124
+ }
125
+ if (wasHalfOpen) {
126
+ updated.isOpen = false;
127
+ updated.halfOpen = false;
128
+ }
129
+ const isIdle = updated.failures === 0 && !updated.isOpen && !updated.halfOpen;
130
+ if (isIdle) {
131
+ syncCtx.store.delete(NS + key);
132
+ }
133
+ else {
134
+ setState(syncCtx.store, key, updated);
135
+ }
50
136
  return result;
51
137
  }
52
138
  catch (err) {
53
- // Signal aborts are not downstream failures don't count them
54
- if (isAbortError(err) || (signal.aborted && err === signal.reason)) {
55
- const updated = getState(syncCtx.store, key);
56
- if (updated.halfOpen) {
57
- // Abort during half-open: probe didn't succeed or fail —
58
- // downstream health is still unknown. Go back to OPEN with
59
- // fresh cooldown so next call waits before probing again.
60
- updated.isOpen = true;
61
- updated.openedAt = Date.now();
62
- updated.halfOpen = false;
139
+ if (signal.aborted && (isAbortError(err) || err === signal.reason)) {
140
+ const updated = getState(syncCtx.store, key, strategy);
141
+ if (updated.failures === 0 && !updated.isOpen && !updated.halfOpen) {
142
+ syncCtx.store.delete(NS + key);
143
+ }
144
+ else {
145
+ setState(syncCtx.store, key, updated);
63
146
  }
64
- setState(syncCtx.store, key, updated);
65
147
  throw err;
66
148
  }
67
- const updated = getState(syncCtx.store, key);
68
- updated.failures++;
149
+ const updated = getState(syncCtx.store, key, strategy);
150
+ const wasHalfOpen = updated.halfOpen;
69
151
  updated.lastFailureTime = Date.now();
70
152
  updated.halfOpen = false;
71
- if (updated.failures >= threshold) {
72
- updated.isOpen = true;
73
- updated.openedAt = Date.now();
153
+ if (updated.strategy === 'count') {
154
+ recordCountOutcome(updated, false, countSize);
155
+ const rate = countFailureRate(updated);
156
+ const enoughCalls = updated.filled >= countMinimumCalls;
157
+ if (wasHalfOpen || (enoughCalls && rate > countThreshold)) {
158
+ updated.isOpen = true;
159
+ updated.openedAt = Date.now();
160
+ }
161
+ }
162
+ else {
163
+ updated.failures++;
164
+ if (updated.failures >= threshold || wasHalfOpen) {
165
+ updated.isOpen = true;
166
+ updated.openedAt = Date.now();
167
+ }
74
168
  }
75
169
  setState(syncCtx.store, key, updated);
76
170
  throw err;
@@ -80,4 +174,3 @@ export function circuitBreakerPolicy(opts) {
80
174
  applier[REQUIRES_SYNC_STORE] = true;
81
175
  return applier;
82
176
  }
83
- //# sourceMappingURL=circuitBreaker.js.map
@@ -3,91 +3,47 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.dedupePolicy = dedupePolicy;
4
4
  const executor_js_1 = require("../core/executor.js");
5
5
  const abort_js_1 = require("../utils/abort.js");
6
- // Namespace so dedupe keys never collide with cache keys in the shared store.
6
+ const safeCall_js_1 = require("../utils/safeCall.js");
7
+ const limits_js_1 = require("../utils/limits.js");
7
8
  const NS = 'dedupe:';
8
- /**
9
- * Module-level generation counter. Monotonic — guarantees uniqueness
10
- * across the lifetime of the process. Wraps at `Number.MAX_SAFE_INTEGER`
11
- * (which would take ~285 000 years at 1M increments/sec).
12
- */
13
9
  let generationCounter = 0;
14
10
  function nextGeneration() {
15
11
  generationCounter = (generationCounter + 1) % Number.MAX_SAFE_INTEGER;
16
12
  return generationCounter;
17
13
  }
18
- /**
19
- * Collapse concurrent calls that share the same key into one in-flight Promise.
20
- *
21
- * # Properties
22
- *
23
- * - **Generation-safe cleanup**: stale originators never delete newer
24
- * entries when `inflightTtl` triggers replacement.
25
- * - **Joiner isolation**: originator's caller-signal abort does NOT
26
- * propagate to joiners. Each joiner races the shared in-flight promise
27
- * against their OWN signal only.
28
- * - **Truthful joiner attempts**: joiners that abort before the originator
29
- * settles report `attempts: 0` (they did no work), not the originator's
30
- * in-progress count.
31
- *
32
- * # INVARIANT: requires SyncStateStore
33
- *
34
- * The read-then-write that makes deduplication work must happen in a single
35
- * synchronous frame. An async store would introduce an `await` between
36
- * `get()` and `set()`, letting two concurrent callers both see a miss and
37
- * both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
38
- * `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
39
- * that bypass TypeScript.
40
- */
41
14
  function dedupePolicy(opts = { enabled: true }) {
42
- const inflightTtl = opts.inflightTtl;
15
+ const inflightTtl = opts.inflightTtl ?? limits_js_1.LIMITS.DEFAULT_INFLIGHT_TTL;
43
16
  const applier = (fn, ctx) => {
44
- // Cast is safe: execute() verifies isSyncStore(ctx.store) before calling
45
- // any policy tagged with REQUIRES_SYNC_STORE.
46
17
  const syncCtx = ctx;
47
18
  return async (signal) => {
48
19
  const key = NS + syncCtx.key;
49
- // ─── Fast path: an in-flight promise already exists. Join it. ──────
50
- //
51
- // The joiner races the in-flight promise against THEIR OWN signal.
52
- // They do NOT inherit the originator's signal state — if the
53
- // originator's caller cancels, joiners continue waiting (or get
54
- // the eventual settled value).
55
20
  const existing = syncCtx.store.get(key);
56
21
  if (existing) {
22
+ const obs = syncCtx.observability;
23
+ if (obs) {
24
+ obs.joinerCounter++;
25
+ (0, safeCall_js_1.safeCall)(obs.hooks.onDedupeJoin, {
26
+ type: 'dedupe-join', key: syncCtx.key, traceId: obs.traceId,
27
+ timestamp: Date.now(), joinerPosition: obs.joinerCounter,
28
+ });
29
+ }
57
30
  try {
58
31
  const value = await (0, abort_js_1.raceAbort)(existing.promise, signal);
59
- // Success — copy originator's final meta (attempts, source)
60
- // so the joiner's ActResult reflects the real effort.
61
32
  syncCtx.meta.attempts = existing.meta.attempts;
62
33
  syncCtx.meta.source = existing.meta.source;
63
34
  return value;
64
35
  }
65
36
  catch (err) {
66
- // Joiner either aborted (their own signal) or got the
67
- // originator's settled error.
68
37
  if (signal.aborted) {
69
- // Joiner's own signal aborted before originator settled.
70
- // They did no work — report `attempts: 0` (truthful).
71
38
  syncCtx.meta.attempts = 0;
72
- // source stays 'fresh' (default) — joiner didn't read from cache
73
39
  }
74
40
  else {
75
- // Originator settled with failure — copy its final meta.
76
41
  syncCtx.meta.attempts = existing.meta.attempts;
77
42
  syncCtx.meta.source = existing.meta.source;
78
43
  }
79
44
  throw err;
80
45
  }
81
46
  }
82
- // ─── Originator path: start the work and publish the promise. ──────
83
- //
84
- // The stored promise is the RAW fn(signal) — NOT raceAbort-wrapped.
85
- // This is critical: if we stored raceAbort(fn, signal), an
86
- // originator signal abort would cause the stored promise to reject,
87
- // which would propagate to all joiners. Instead:
88
- // - stored: raw fn(signal) — joiners await this
89
- // - originator's await: raceAbort(stored, signal) — originator
90
- // can bail out on their own signal without affecting joiners
91
47
  const generation = nextGeneration();
92
48
  const rawPromise = Promise.resolve(fn(signal));
93
49
  const entry = {
@@ -96,20 +52,17 @@ function dedupePolicy(opts = { enabled: true }) {
96
52
  generation,
97
53
  };
98
54
  syncCtx.store.set(key, entry, inflightTtl);
99
- // Cleanup on settle: only delete if generation matches. If a newer
100
- // originator has replaced this entry (because inflightTtl expired),
101
- // our cleanup is a no-op — the newer entry stays.
102
55
  const cleanup = () => {
103
- const current = syncCtx.store.get(key);
104
- if (current && current.generation === generation) {
105
- syncCtx.store.delete(key);
56
+ try {
57
+ const current = syncCtx.store.get(key);
58
+ if (current && current.generation === generation) {
59
+ syncCtx.store.delete(key);
60
+ }
61
+ }
62
+ catch {
106
63
  }
107
64
  };
108
- // Attach cleanup to the RAW promise (not the raceAbort-wrapped one)
109
- // so cleanup runs whenever fn settles, regardless of originator abort.
110
- rawPromise.then(cleanup, cleanup);
111
- // Originator races the raw promise against their own signal —
112
- // they can bail out without affecting joiners.
65
+ rawPromise.then(cleanup, cleanup).catch(() => { });
113
66
  return (0, abort_js_1.raceAbort)(rawPromise, signal);
114
67
  };
115
68
  };
@@ -1,26 +1,3 @@
1
1
  import type { PolicyApplier, DedupeOptions } from '../types/index.js';
2
- /**
3
- * Collapse concurrent calls that share the same key into one in-flight Promise.
4
- *
5
- * # Properties
6
- *
7
- * - **Generation-safe cleanup**: stale originators never delete newer
8
- * entries when `inflightTtl` triggers replacement.
9
- * - **Joiner isolation**: originator's caller-signal abort does NOT
10
- * propagate to joiners. Each joiner races the shared in-flight promise
11
- * against their OWN signal only.
12
- * - **Truthful joiner attempts**: joiners that abort before the originator
13
- * settles report `attempts: 0` (they did no work), not the originator's
14
- * in-progress count.
15
- *
16
- * # INVARIANT: requires SyncStateStore
17
- *
18
- * The read-then-write that makes deduplication work must happen in a single
19
- * synchronous frame. An async store would introduce an `await` between
20
- * `get()` and `set()`, letting two concurrent callers both see a miss and
21
- * both launch work. The `REQUIRES_SYNC_STORE` symbol on the returned
22
- * `PolicyApplier` lets `execute()` enforce this at runtime for JS callers
23
- * that bypass TypeScript.
24
- */
25
2
  export declare function dedupePolicy<T>(opts?: DedupeOptions): PolicyApplier<T>;
26
3
  //# sourceMappingURL=dedupe.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../../src/policies/dedupe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,aAAa,EAAE,MAAM,mBAAmB,CAAA;AA6D3F;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,IAAI,GAAE,aAAiC,GAAG,aAAa,CAAC,CAAC,CAAC,CAqFzF"}
1
+ {"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../../src/policies/dedupe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,aAAa,EAAiB,aAAa,EAAE,MAAM,mBAAmB,CAAA;AA0D3F,wBAAgB,YAAY,CAAC,CAAC,EAAE,IAAI,GAAE,aAAiC,GAAG,aAAa,CAAC,CAAC,CAAC,CA6FzF"}