schematichq 1.5.2 → 1.5.3

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.fern/metadata.json +3 -3
  3. data/.fern/replay.lock +8 -338
  4. data/.fernignore +6 -0
  5. data/README.md +141 -0
  6. data/custom.gemspec.rb +6 -0
  7. data/lib/schematic/accounts/types/update_environment_request_body.rb +2 -0
  8. data/lib/schematic/client.rb +1 -1
  9. data/lib/schematic/credits/client.rb +50 -6
  10. data/lib/schematic/credits/leases/check.rb +477 -0
  11. data/lib/schematic/credits/leases/lease_manager.rb +565 -0
  12. data/lib/schematic/credits/leases/lease_store.rb +240 -0
  13. data/lib/schematic/credits/leases/redis_lease_store.rb +358 -0
  14. data/lib/schematic/credits/leases/redis_reservation_store.rb +326 -0
  15. data/lib/schematic/credits/leases/reservation_store.rb +161 -0
  16. data/lib/schematic/credits/leases/server_check.rb +237 -0
  17. data/lib/schematic/credits/leases/track.rb +66 -0
  18. data/lib/schematic/credits/leases/types.rb +329 -0
  19. data/lib/schematic/credits/leases/wire_client.rb +83 -0
  20. data/lib/schematic/credits/types/acquire_credit_lease_request_body.rb +2 -0
  21. data/lib/schematic/credits/types/create_credit_spend_policy_request_body.rb +5 -1
  22. data/lib/schematic/credits/types/extend_credit_lease_request_body.rb +2 -0
  23. data/lib/schematic/credits/types/get_credit_spend_policy_usage_params.rb +16 -0
  24. data/lib/schematic/credits/types/get_credit_spend_policy_usage_request.rb +15 -0
  25. data/lib/schematic/credits/types/get_credit_spend_policy_usage_response.rb +13 -0
  26. data/lib/schematic/credits/types/update_credit_spend_policy_request_body.rb +4 -0
  27. data/lib/schematic/datastream/client.rb +32 -2
  28. data/lib/schematic/entitlements/client.rb +165 -0
  29. data/lib/schematic/entitlements/types/count_company_user_usage_params.rb +24 -0
  30. data/lib/schematic/entitlements/types/count_company_user_usage_request.rb +23 -0
  31. data/lib/schematic/entitlements/types/count_company_user_usage_response.rb +13 -0
  32. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_params.rb +16 -0
  33. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_request.rb +15 -0
  34. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_response.rb +13 -0
  35. data/lib/schematic/entitlements/types/list_company_user_usage_params.rb +24 -0
  36. data/lib/schematic/entitlements/types/list_company_user_usage_request.rb +23 -0
  37. data/lib/schematic/entitlements/types/list_company_user_usage_response.rb +13 -0
  38. data/lib/schematic/plangroups/client.rb +2 -0
  39. data/lib/schematic/plangroups/types/create_plan_group_request_body.rb +2 -0
  40. data/lib/schematic/plangroups/types/update_plan_group_request_body.rb +2 -0
  41. data/lib/schematic/planmigrations/client.rb +6 -0
  42. data/lib/schematic/planmigrations/types/count_migrations_params.rb +2 -0
  43. data/lib/schematic/planmigrations/types/count_migrations_request.rb +2 -0
  44. data/lib/schematic/planmigrations/types/create_migration_input.rb +2 -0
  45. data/lib/schematic/planmigrations/types/list_migrations_params.rb +2 -0
  46. data/lib/schematic/planmigrations/types/list_migrations_request.rb +2 -0
  47. data/lib/schematic/plans/types/publish_plan_version_request_body.rb +4 -0
  48. data/lib/schematic/plans/types/retry_custom_plan_billing_request_body.rb +2 -0
  49. data/lib/schematic/rules_engine.rb +37 -0
  50. data/lib/schematic/schematic_client.rb +830 -33
  51. data/lib/schematic/types/capture_raw_event.rb +4 -0
  52. data/lib/schematic/types/check_flags_response_data.rb +2 -0
  53. data/lib/schematic/types/company_detail_response_data.rb +2 -0
  54. data/lib/schematic/types/company_plan_detail_response_data.rb +2 -0
  55. data/lib/schematic/types/company_user_usage_metrics_response_data.rb +15 -0
  56. data/lib/schematic/types/company_user_usage_response_data.rb +19 -0
  57. data/lib/schematic/types/company_user_usage_row_response_data.rb +17 -0
  58. data/lib/schematic/types/component_display_settings.rb +2 -0
  59. data/lib/schematic/types/component_settings_response_data.rb +2 -0
  60. data/lib/schematic/types/credit_event_ledger_response_data.rb +5 -1
  61. data/lib/schematic/types/credit_event_type.rb +3 -0
  62. data/lib/schematic/types/credit_ledger_entry_kind.rb +17 -0
  63. data/lib/schematic/types/credit_spend_policy.rb +25 -0
  64. data/lib/schematic/types/credit_spend_policy_response_data.rb +12 -0
  65. data/lib/schematic/types/credit_spend_window.rb +11 -0
  66. data/lib/schematic/types/credit_spend_window_unit.rb +13 -0
  67. data/lib/schematic/types/custom_plan_billing_response_data.rb +2 -0
  68. data/lib/schematic/types/environment_detail_response_data.rb +2 -0
  69. data/lib/schematic/types/environment_response_data.rb +2 -0
  70. data/lib/schematic/types/estimated_plan_total.rb +13 -0
  71. data/lib/schematic/types/pending_migration_response_data.rb +6 -0
  72. data/lib/schematic/types/plan_version_migration_response_data.rb +2 -0
  73. data/lib/schematic/types/plan_version_migration_strategy.rb +1 -0
  74. data/lib/schematic/types/rules_engine_schema_version.rb +1 -1
  75. data/lib/schematic/types/rulesengine_company.rb +2 -0
  76. data/lib/schematic/types/rulesengine_credit_spend_policy.rb +25 -0
  77. data/lib/schematic/types/rulesengine_credit_spend_policy_scope.rb +13 -0
  78. data/lib/schematic/types/rulesengine_credit_spend_window.rb +11 -0
  79. data/lib/schematic/types/rulesengine_user.rb +2 -0
  80. data/lib/schematic/types/user_usage_metric.rb +12 -0
  81. data/lib/schematic/version.rb +1 -1
  82. data/lib/schematic.rb +26 -2
  83. data/lib/schematichq.rb +10 -0
  84. data/reference.md +474 -9
  85. metadata +36 -2
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Schematic
6
+ module Credits
7
+ module Leases
8
+ # Redis-backed reservation table. Each reservation is a hash, indexed by
9
+ # expires_at in a sorted set so the sweeper can pop expired entries in
10
+ # O(log n), and by (company, credit) so a balance display can sum a
11
+ # tenant's open holds.
12
+ #
13
+ # Every mutation is a single-key operation or single-key Lua, so the store
14
+ # is correct on standalone and clustered Redis alike: the unspent-slice
15
+ # refund is delegated to the lease store rather than reaching across to
16
+ # the lease hash inside a multi-key script.
17
+ class RedisReservationStore
18
+ DEFAULT_KEY_PREFIX = "schematic:"
19
+ RES_KEY_NAMESPACE = "credit-reservation:"
20
+ # Sorted set scoring open reservations by expires_at. Members encode the
21
+ # full (company, credit, id) tuple so the sweeper can clean the
22
+ # per-tenant hash even after the reservation hash has TTL-evicted, at
23
+ # which point the claim returns nil and cannot report company or credit;
24
+ # otherwise the orphaned field would permanently inflate reserved_credits.
25
+ RES_INDEX_KEY = "credit-reservations:byExpiry"
26
+ # Per-(company, credit) index of open reservations, one hash of
27
+ # reservationId to creditsReserved. reserved_credits then reads a whole
28
+ # tenant's holds with ONE HGETALL (single key, Cluster-safe) and sums the
29
+ # values. The hash also IS the source of truth for the sum: a field
30
+ # exists iff its reservation is open and unrefunded.
31
+ RES_BYCREDIT_NAMESPACE = "credit-reservations:byCredit:"
32
+ # Buffer past expires_at before Redis auto-evicts the row, so the
33
+ # sweeper has a window to refund.
34
+ RES_TTL_GRACE_MS = 30_000
35
+ # The delimiter is absent from Schematic ids and from the UUID
36
+ # reservation id.
37
+ MEMBER_DELIMITER = "|"
38
+ # Page size for the sweeper's ZRANGEBYSCORE. Without a limit, a backlog
39
+ # of expired holds would come back as one giant reply on every tick.
40
+ SWEEP_BATCH_SIZE = 256
41
+ # Upper bound on pages per tick. Keeps one sweep's work bounded and
42
+ # guards against an endless loop if a zrem persistently fails. Anything
43
+ # left over is picked up next tick.
44
+ MAX_SWEEP_BATCHES = 16
45
+
46
+ # Atomic claim: read the reservation hash and delete it in one step,
47
+ # returning its fields, or nil if it was already gone. One key, so it is
48
+ # Cluster-safe. The atomic read-then-delete is what makes consume
49
+ # exactly-once: of two racing callers only one gets the fields back and
50
+ # proceeds to refund. The refund is a separate single-key op; a crash in
51
+ # the gap leaves the unspent slice held on the lease until the lease
52
+ # itself expires, never double-refunded.
53
+ CLAIM_SCRIPT = Script.new(<<~LUA)
54
+
55
+ local raw = redis.call('HGETALL', KEYS[1])
56
+ if #raw == 0 then return nil end
57
+ redis.call('DEL', KEYS[1])
58
+ return raw
59
+ LUA
60
+
61
+ def initialize(client:, lease_store:, sweep_interval_ms: DEFAULT_SWEEP_INTERVAL_MS, key_prefix: nil,
62
+ clock: DEFAULT_CLOCK, logger: nil)
63
+ @client = client
64
+ @lease_store = lease_store
65
+ @sweep_interval_ms = sweep_interval_ms
66
+ @key_prefix = key_prefix || DEFAULT_KEY_PREFIX
67
+ @clock = clock
68
+ @logger = logger
69
+ @mutex = Mutex.new
70
+ @sweep_thread = nil
71
+ @stopped = false
72
+ end
73
+
74
+ def add(reservation)
75
+ expires_ms = millis(reservation.expires_at)
76
+ key = hash_key(reservation.id)
77
+ fields = [
78
+ "id", reservation.id,
79
+ "leaseId", reservation.lease_id,
80
+ "companyId", reservation.company_id,
81
+ "creditTypeId", reservation.credit_type_id,
82
+ "eventSubtype", reservation.event_subtype.to_s,
83
+ "quantityReserved", reservation.quantity_reserved.to_s,
84
+ "creditsReserved", reservation.credits_reserved.to_s,
85
+ "consumptionRate", reservation.consumption_rate.to_s,
86
+ "expiresAt", expires_ms.to_s,
87
+ "evalCtx", JSON.generate(reservation.eval_ctx)
88
+ ]
89
+ ttl_at = expires_ms + RES_TTL_GRACE_MS
90
+ # The hash and its expiry go out as one MULTI/EXEC. Written
91
+ # separately, a crash in the gap leaves a reservation row with no TTL:
92
+ # once the sweeper drops its index entry, nothing points at the row
93
+ # and nothing reaps it, so it sits in Redis for good. Same commands,
94
+ # same key, same fields, so what other SDKs read is unchanged, and
95
+ # both commands touch the one key, so this is Cluster-safe.
96
+ if @client.respond_to?(:multi)
97
+ @client.multi do |tx|
98
+ tx.hset(key, *fields)
99
+ tx.pexpireat(key, ttl_at)
100
+ end
101
+ else
102
+ @client.hset(key, *fields)
103
+ @client.pexpireat(key, ttl_at)
104
+ end
105
+ # The two indexes (expiry zset for the sweeper, per-tenant hash for
106
+ # reserved_credits) only depend on the hash existing, so they stay
107
+ # outside the transaction: their keys hash to other slots. A partial
108
+ # failure here at worst leaves an un-indexed reservation that the TTL
109
+ # reaps, never a double spend.
110
+ @client.zadd(index_key, expires_ms, encode_member(reservation.company_id, reservation.credit_type_id,
111
+ reservation.id))
112
+ @client.hset(by_credit_key(reservation.company_id, reservation.credit_type_id), reservation.id,
113
+ reservation.credits_reserved.to_s)
114
+ nil
115
+ end
116
+
117
+ def get(id)
118
+ raw = @client.hgetall(hash_key(id))
119
+ return nil if raw.nil? || raw["id"].nil?
120
+
121
+ decode_reservation(raw)
122
+ end
123
+
124
+ # Sum a tenant's open holds with a single HGETALL on the per-tenant
125
+ # index hash: one round trip, one key, no per-reservation fan-out. A
126
+ # field is present iff its reservation is open and unrefunded, so the
127
+ # sum is exact.
128
+ def reserved_credits(company_id, credit_type_id)
129
+ by_credit = begin
130
+ @client.hgetall(by_credit_key(company_id, credit_type_id))
131
+ rescue StandardError
132
+ {}
133
+ end
134
+ (by_credit || {}).values.sum(&:to_f)
135
+ end
136
+
137
+ def consume(id, credits_consumed)
138
+ # Atomically claim the reservation hash. Only one caller wins; a
139
+ # duplicate or racing consume gets nil.
140
+ claimed = CLAIM_SCRIPT.call(@client, keys: [hash_key(id)], argv: [])
141
+ raw = decode_raw_array(claimed)
142
+ return nil if raw.nil? || raw["id"].nil?
143
+
144
+ company_id = raw["companyId"]
145
+ credit_type_id = raw["creditTypeId"]
146
+ reserved = raw["creditsReserved"].to_f
147
+
148
+ # Index cleanup, single-key ops. The credits leave the per-tenant hash
149
+ # BEFORE the refund below so the lease (local remaining plus this
150
+ # hash) never transiently double-counts the slice, and before the
151
+ # expiry index because that index is the only way the sweeper reaches
152
+ # a surviving field: dropping the index first and then failing on the
153
+ # field would inflate reserved_credits for that tenant forever.
154
+ swallow { @client.hdel(by_credit_key(company_id, credit_type_id), id) }
155
+ swallow { @client.zrem(index_key, encode_member(company_id, credit_type_id, id)) }
156
+
157
+ consumed = credits_consumed.clamp(0, reserved)
158
+ refund = reserved - consumed
159
+ # A hold that cannot name its lease is not refundable: the refund
160
+ # script reads an empty pin as no pin and would credit whichever lease
161
+ # holds the slot now, possibly a successor. The slice comes back when
162
+ # the lease expires instead, the same call the per-process store makes.
163
+ if refund.positive? && !raw["leaseId"].to_s.empty?
164
+ # Delegated to the lease store, which owns the lease hash, so the
165
+ # cross-key write stays out of a single Lua script. Pinned to the
166
+ # reservation's leaseId so a hold carved out of an expired lease
167
+ # cannot inflate a successor lease's balance.
168
+ @lease_store.refund(company_id, credit_type_id, refund, raw["leaseId"])
169
+ end
170
+ consumed
171
+ end
172
+
173
+ def start_sweep
174
+ @mutex.synchronize do
175
+ # alive?, not presence: a process that forks after start (Puma or
176
+ # Unicorn with preload) hands the child a thread object whose thread
177
+ # did not survive the fork, and the child would never sweep.
178
+ return if @sweep_thread&.alive? || @stopped
179
+
180
+ interval = @sweep_interval_ms.to_f / 1000.0
181
+ @sweep_thread = Thread.new do
182
+ loop do
183
+ sleep(interval)
184
+ break if @stopped
185
+
186
+ begin
187
+ sweep_expired
188
+ rescue StandardError => e
189
+ @logger&.debug("Reservation sweep failed: #{e.message}")
190
+ end
191
+ end
192
+ end
193
+ @sweep_thread.abort_on_exception = false
194
+ end
195
+ end
196
+
197
+ def sweep_expired(now = nil)
198
+ cutoff = millis(now || @clock.call)
199
+ swept = 0
200
+ # Page through expired members rather than fetching them all at once.
201
+ # Each processed member is removed below, so re-reading at offset 0
202
+ # advances through the backlog.
203
+ MAX_SWEEP_BATCHES.times do
204
+ expired = @client.zrangebyscore(index_key, 0, cutoff, limit: [0, SWEEP_BATCH_SIZE]) || []
205
+ break if expired.empty?
206
+
207
+ expired.each { |member| swept += 1 if member_refunded?(member) }
208
+ break if expired.size < SWEEP_BATCH_SIZE
209
+ end
210
+ swept
211
+ end
212
+
213
+ def stop
214
+ thread = @mutex.synchronize do
215
+ @stopped = true
216
+ thread = @sweep_thread
217
+ @sweep_thread = nil
218
+ thread
219
+ end
220
+ return nil if thread.nil?
221
+
222
+ # A sweep past its claim has deleted the reservation but not yet
223
+ # refunded the lease, and killing it there strands the unspent slice
224
+ # until the lease expires. Give it a bounded moment to land that
225
+ # refund, then kill: the loop checks @stopped as soon as its sleep
226
+ # ends, so a parked sweeper costs at most this wait.
227
+ thread.kill unless thread.join(SWEEP_STOP_JOIN_MS / 1000.0)
228
+ nil
229
+ end
230
+
231
+ def size
232
+ @client.zcard(index_key)
233
+ rescue StandardError
234
+ 0
235
+ end
236
+
237
+ private
238
+
239
+ def member_refunded?(member)
240
+ decoded = decode_member(member)
241
+ if decoded.nil?
242
+ # Nothing but add writes these, so this is belt and braces. Drop it
243
+ # so an unparseable member cannot wedge the sweeper.
244
+ swallow { @client.zrem(index_key, member) }
245
+ return false
246
+ end
247
+ company_id, credit_type_id, id = decoded
248
+ refunded = consume(id, 0)
249
+ # Always drop the member just read. On the success path consume
250
+ # already removed it, so this is an idempotent no-op; it also covers
251
+ # the hash-evicted path below.
252
+ swallow { @client.zrem(index_key, member) }
253
+ return true unless refunded.nil?
254
+
255
+ # The claim found no reservation hash. Either a racing track already
256
+ # consumed it and reconciled the byCredit field, or the hash
257
+ # TTL-evicted before the sweeper reached it, orphaning that field.
258
+ # Reconcile it so reserved_credits cannot keep summing an evicted
259
+ # hold. The unspent slice is NOT refunded here: without the hash the
260
+ # claim cannot arbitrate exactly-once across racing sweepers, so the
261
+ # slice is reclaimed when the lease itself expires server-side.
262
+ swallow { @client.hdel(by_credit_key(company_id, credit_type_id), id) }
263
+ false
264
+ end
265
+
266
+ def hash_key(id)
267
+ "#{@key_prefix}#{RES_KEY_NAMESPACE}#{id}"
268
+ end
269
+
270
+ def index_key
271
+ "#{@key_prefix}#{RES_INDEX_KEY}"
272
+ end
273
+
274
+ def by_credit_key(company_id, credit_type_id)
275
+ "#{@key_prefix}#{RES_BYCREDIT_NAMESPACE}#{company_id}:#{credit_type_id}"
276
+ end
277
+
278
+ def encode_member(company_id, credit_type_id, id)
279
+ [company_id, credit_type_id, id].join(MEMBER_DELIMITER)
280
+ end
281
+
282
+ def decode_member(member)
283
+ parts = member.to_s.split(MEMBER_DELIMITER)
284
+ return nil unless parts.size == 3
285
+
286
+ parts
287
+ end
288
+
289
+ def millis(time)
290
+ (time.to_f * 1000).round
291
+ end
292
+
293
+ def swallow
294
+ yield
295
+ rescue StandardError
296
+ nil
297
+ end
298
+
299
+ # Decode a flat [field, value, field, value, ...] HGETALL array, which
300
+ # is what the claim script returns.
301
+ def decode_raw_array(raw)
302
+ return nil unless raw.is_a?(Array) && !raw.empty?
303
+
304
+ out = {}
305
+ raw.each_slice(2) { |field, value| out[field.to_s] = value.to_s if value }
306
+ out
307
+ end
308
+
309
+ def decode_reservation(raw)
310
+ Reservation.new(
311
+ id: raw["id"],
312
+ lease_id: raw["leaseId"],
313
+ company_id: raw["companyId"],
314
+ credit_type_id: raw["creditTypeId"],
315
+ event_subtype: raw["eventSubtype"],
316
+ quantity_reserved: raw["quantityReserved"].to_f,
317
+ credits_reserved: raw["creditsReserved"].to_f,
318
+ consumption_rate: raw["consumptionRate"].to_f,
319
+ expires_at: Time.at(raw["expiresAt"].to_f / 1000.0),
320
+ eval_ctx: raw["evalCtx"] ? JSON.parse(raw["evalCtx"], symbolize_names: true) : {}
321
+ )
322
+ end
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematic
4
+ module Credits
5
+ module Leases
6
+ # Per-process reservation table, paired with a sweep loop that returns
7
+ # expired reservations to their underlying lease.
8
+ #
9
+ # For cross-process deployments use RedisReservationStore instead: both
10
+ # answer the same calls with the same semantics.
11
+ class ReservationStore
12
+ def initialize(lease_store, sweep_interval_ms = DEFAULT_SWEEP_INTERVAL_MS, clock: DEFAULT_CLOCK,
13
+ logger: nil)
14
+ @lease_store = lease_store
15
+ @sweep_interval_ms = sweep_interval_ms
16
+ @clock = clock
17
+ @logger = logger
18
+ @reservations = {}
19
+ @mutex = Mutex.new
20
+ @sweep_thread = nil
21
+ @stopped = false
22
+ end
23
+
24
+ # Register a new reservation. Idempotent on id. It does NOT debit the
25
+ # lease: the debit already happened in try_reserve, and recording the
26
+ # hold after the debit is what bounds a crash between them to leaked
27
+ # credits rather than a double spend.
28
+ def add(reservation)
29
+ @mutex.synchronize { @reservations[reservation.id] = reservation }
30
+ nil
31
+ end
32
+
33
+ def get(id)
34
+ @mutex.synchronize { @reservations[id] }
35
+ end
36
+
37
+ # Sum the open reservations for a (company, credit). A reservation
38
+ # counts while it is in the table: its credits stay carved out of the
39
+ # lease's local_remaining_credits until consume or sweep_expired removes
40
+ # it and refunds the unspent remainder in the same step, so
41
+ # local_remaining_credits + reserved_credits stays exact.
42
+ def reserved_credits(company_id, credit_type_id)
43
+ @mutex.synchronize do
44
+ @reservations.each_value.sum do |reservation|
45
+ if reservation.company_id == company_id && reservation.credit_type_id == credit_type_id
46
+ reservation.credits_reserved
47
+ else
48
+ 0
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ # Claim a reservation exactly once and settle it: the clamped consumed
55
+ # slice stays debited and the unspent remainder is refunded to the
56
+ # lease. Returns the clamped figure, or nil when the reservation was
57
+ # already gone (swept at its TTL, or claimed by a racing caller).
58
+ #
59
+ # The claim is the arbiter. A crash between the claim and the refund
60
+ # leaks the unspent slice until the lease expires, which is the safe
61
+ # direction: a second claim finds nothing and refunds nothing.
62
+ def consume(id, credits_consumed)
63
+ reservation = @mutex.synchronize { @reservations.delete(id) }
64
+ return nil if reservation.nil?
65
+
66
+ actual = credits_consumed.clamp(0, reservation.credits_reserved)
67
+ refund = reservation.credits_reserved - actual
68
+ if refund.positive? && refundable?(reservation)
69
+ # Pinned to the originating lease: if that lease has expired and a
70
+ # successor holds the slot, the refund is dropped, because the
71
+ # expired lease's remainder was already returned server-side.
72
+ @lease_store.refund(reservation.company_id, reservation.credit_type_id, refund, reservation.lease_id)
73
+ end
74
+ actual
75
+ end
76
+
77
+ # Start the background sweep loop. Safe to call repeatedly.
78
+ def start_sweep
79
+ @mutex.synchronize do
80
+ # alive?, not presence: a process that forks after start (Puma or
81
+ # Unicorn with preload) hands the child a thread object whose thread
82
+ # did not survive the fork, and the child would never sweep.
83
+ return if @sweep_thread&.alive? || @stopped
84
+
85
+ interval = @sweep_interval_ms.to_f / 1000.0
86
+ @sweep_thread = Thread.new do
87
+ loop do
88
+ sleep(interval)
89
+ break if @stopped
90
+
91
+ begin
92
+ sweep_expired
93
+ rescue StandardError => e
94
+ # Keep the loop alive: a store blip must not stop the sweeper.
95
+ @logger&.debug("Reservation sweep failed: #{e.message}")
96
+ end
97
+ end
98
+ end
99
+ @sweep_thread.abort_on_exception = false
100
+ end
101
+ end
102
+
103
+ # Remove every reservation past its TTL and refund its full hold to its
104
+ # lease. Returns the number swept.
105
+ def sweep_expired(now = nil)
106
+ now ||= @clock.call
107
+ expired = @mutex.synchronize do
108
+ due = @reservations.each_value.select { |r| r.expires_at.to_f <= now.to_f }
109
+ due.each { |r| @reservations.delete(r.id) }
110
+ due
111
+ end
112
+ expired.each do |reservation|
113
+ next unless refundable?(reservation)
114
+
115
+ @lease_store.refund(
116
+ reservation.company_id,
117
+ reservation.credit_type_id,
118
+ reservation.credits_reserved,
119
+ reservation.lease_id
120
+ )
121
+ end
122
+ expired.size
123
+ end
124
+
125
+ def stop
126
+ thread = @mutex.synchronize do
127
+ @stopped = true
128
+ thread = @sweep_thread
129
+ @sweep_thread = nil
130
+ thread
131
+ end
132
+ return nil if thread.nil?
133
+
134
+ # A sweep past its claim has deleted the reservation but not yet
135
+ # refunded the lease, and killing it there strands the unspent slice
136
+ # until the lease expires. Give it a bounded moment to land that
137
+ # refund, then kill: the loop checks @stopped as soon as its sleep
138
+ # ends, so a parked sweeper costs at most this wait.
139
+ thread.kill unless thread.join(SWEEP_STOP_JOIN_MS / 1000.0)
140
+ nil
141
+ end
142
+
143
+ def size
144
+ @mutex.synchronize { @reservations.size }
145
+ end
146
+
147
+ private
148
+
149
+ # A hold that cannot name the lease it came out of is not refundable:
150
+ # crediting whichever lease holds the slot now could inflate a successor
151
+ # whose grant the server issued whole, and the slice comes back when the
152
+ # lease expires anyway. Decided here rather than left to the lease
153
+ # store, because the two lease stores read a missing pin differently,
154
+ # and a fleet mixing SDKs on one Redis must agree.
155
+ def refundable?(reservation)
156
+ !reservation.lease_id.nil? && !reservation.lease_id.to_s.empty?
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end