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,565 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Schematic
6
+ module Credits
7
+ module Leases
8
+ # The monotonic-clock millisecond deadline a caller's timeout sets for
9
+ # waiting on a shared flight, or nil for no cap. Taken once, at check
10
+ # start, so every wait the check makes draws on the same budget.
11
+ def self.join_deadline(request_options)
12
+ seconds = request_options.is_a?(Hash) ? request_options[:timeout_in_seconds] : nil
13
+ return nil unless seconds.is_a?(Numeric) && seconds.to_f.finite?
14
+
15
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000) + (seconds * 1000)
16
+ end
17
+
18
+ # One wire call in flight for a slot, plus the figure it asked for, which
19
+ # is what a joiner compares its own shortfall against. Threads that arrive
20
+ # while it runs wait on it instead of issuing a second call.
21
+ class Flight
22
+ attr_reader :requested_additional
23
+
24
+ def initialize(requested_additional = nil)
25
+ @requested_additional = requested_additional
26
+ @mutex = Mutex.new
27
+ @condition = ConditionVariable.new
28
+ @done = false
29
+ @value = nil
30
+ end
31
+
32
+ def complete(value)
33
+ @mutex.synchronize do
34
+ @value = value
35
+ @done = true
36
+ @condition.broadcast
37
+ end
38
+ end
39
+
40
+ # Wait for the flight to land. A timeout_ms of nil waits indefinitely;
41
+ # a shutdown passes its remaining budget so a stalled wire call cannot
42
+ # hold the close open.
43
+ def wait(timeout_ms = nil)
44
+ deadline = timeout_ms ? monotonic_ms + timeout_ms : nil
45
+ @mutex.synchronize do
46
+ until @done
47
+ if deadline
48
+ remaining = deadline - monotonic_ms
49
+ break if remaining <= 0
50
+
51
+ @condition.wait(@mutex, remaining / 1000.0)
52
+ else
53
+ @condition.wait(@mutex)
54
+ end
55
+ end
56
+ @value
57
+ end
58
+ end
59
+
60
+ def done?
61
+ @mutex.synchronize { @done }
62
+ end
63
+
64
+ private
65
+
66
+ def monotonic_ms
67
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
68
+ end
69
+ end
70
+
71
+ # Owns the lifecycle of credit leases for a single client: acquire on
72
+ # first use or after expiry, extend when the local view dips below the low
73
+ # water mark, release on close.
74
+ #
75
+ # Acquire and extend each get their own single-flight map, kept separate
76
+ # so an in-flight extend can never satisfy an acquire. Best-effort: a
77
+ # caller racing ahead of the registration can still duplicate a wire call,
78
+ # which is safe, because the server is idempotent for an active slot,
79
+ # replace keeps the first live lease, and extend reconciles to a total.
80
+ class LeaseManager
81
+ # A wait on a shared extend that ran out the joiner's own timeout.
82
+ JOIN_TIMED_OUT = Object.new.freeze
83
+
84
+ def initialize(wire_client:, lease_store:, logger:, config: {}, clock: DEFAULT_CLOCK)
85
+ @wire = wire_client
86
+ @lease_store = lease_store
87
+ @logger = logger
88
+ @config = config || {}
89
+ @clock = clock
90
+ @flight_mutex = Mutex.new
91
+ @inflight_acquire = {}
92
+ @inflight_extend = {}
93
+ # Lease work nobody joins: the redundant release a lost acquire race
94
+ # issues, and the background extends callers fire and forget. drain
95
+ # waits these out so a close releases what they installed.
96
+ @background = []
97
+ @stopped = false
98
+ end
99
+
100
+ def resolve_config(credit_type_id)
101
+ Leases.resolve_config(@config, credit_type_id)
102
+ end
103
+
104
+ # Return the slot's lease, acquiring one (or replacing an expired one)
105
+ # if none is live. Never raises: a wire or store failure is logged and
106
+ # reported as nil, so callers route it through their fail-open or
107
+ # fail-closed handling.
108
+ #
109
+ # deadline is a monotonic-clock millisecond cap on waiting for another
110
+ # caller's acquire, fixed once when the check started so the waits a
111
+ # check makes cannot each restart its timeout. Without one the cap is
112
+ # derived from request_options' timeout as of this call.
113
+ def acquire_if_needed(company_id, credit_type_id, request_options = nil, deadline: nil)
114
+ # Past stop the drain has run or is running, so a lease acquired now
115
+ # is one nothing is left to release.
116
+ return log_stopped("acquire", company_id, credit_type_id) if @stopped
117
+
118
+ begin
119
+ existing = @lease_store.get(company_id, credit_type_id)
120
+ rescue StandardError => e
121
+ @logger.error("Failed to read lease store for #{company_id}/#{credit_type_id}: #{e.message}")
122
+ return nil
123
+ end
124
+ return existing if existing && !existing.expired?(@clock.call)
125
+
126
+ # An expired or absent slot is left for replace to overwrite, which
127
+ # guards on expiry and writes atomically. Dropping it first would be a
128
+ # separate op that can interleave between a sibling's read and its
129
+ # replace, clobbering the lease that sibling just installed.
130
+
131
+ # Check again: stop may have landed during the store read.
132
+ return log_stopped("acquire", company_id, credit_type_id) if @stopped
133
+
134
+ key = Leases.lease_key(company_id, credit_type_id)
135
+ flight, leader = enlist(@inflight_acquire, key) { Flight.new }
136
+ return log_stopped("acquire", company_id, credit_type_id) if flight.nil?
137
+ return join_acquire(flight, deadline || join_deadline(request_options), company_id, credit_type_id) unless leader
138
+
139
+ begin
140
+ result = acquire(company_id, credit_type_id, request_options)
141
+ ensure
142
+ @flight_mutex.synchronize { @inflight_acquire.delete(key) }
143
+ flight.complete(result)
144
+ end
145
+ result
146
+ end
147
+
148
+ # Kick off an extend when one is due, on a background thread a caller can
149
+ # fire and forget. Returns the thread, or nil when nothing is due; the
150
+ # check flow joins it when the extend covers a reserve it just failed.
151
+ #
152
+ # Due means at or below the low-water-mark ratio, or below a named
153
+ # required_credits: one large check should not wait for the next
154
+ # sub-watermark check to top the lease up. deadline caps a wait on
155
+ # another caller's extend, as for acquire_if_needed. Never raises.
156
+ def maybe_extend_in_background(company_id, credit_type_id, required_credits = nil, request_options = nil,
157
+ deadline: nil)
158
+ if @stopped
159
+ # Extending past stop re-holds credits on a lease the close is about
160
+ # to release, or has already released.
161
+ log_stopped("extend", company_id, credit_type_id)
162
+ return nil
163
+ end
164
+ # Decided here rather than inside the thread: most checks sit nowhere
165
+ # near the water mark, and spawning one thread per check to learn that
166
+ # costs far more than the store read that answers it.
167
+ return nil unless extend_due?(company_id, credit_type_id, required_credits)
168
+ # An extend already on the wire is fetching the credits a watermark
169
+ # refresh wants, so a thread for it would find the flight and exit.
170
+ # A caller naming required_credits still spawns: it has a reserve to
171
+ # retry, and may need more than the flight asked for.
172
+ return nil if required_credits.nil? && extend_in_flight?(company_id, credit_type_id)
173
+
174
+ # The whole call is tracked, not just the wire call inside it: callers
175
+ # drop the thread on the floor, so between the store read and the
176
+ # extend there would otherwise be a window where a drain sees nothing
177
+ # pending.
178
+ track do
179
+ extend_if_needed(company_id, credit_type_id, required_credits, request_options, deadline)
180
+ end
181
+ end
182
+
183
+ # Refuse new lease work. Idempotent, and paired with drain: stopping
184
+ # first is what makes the drain terminate, since nothing can enqueue
185
+ # behind it. Taken under the flight lock that enlist reads it under, so
186
+ # once stop returns no further flight can register and the drain that
187
+ # follows sees every flight there will ever be.
188
+ def stop
189
+ @flight_mutex.synchronize { @stopped = true }
190
+ nil
191
+ end
192
+
193
+ # Wait out lease work already on the wire, so a close releases what that
194
+ # work installs instead of orphaning it. Bounded: whatever has not
195
+ # landed by the deadline is abandoned rather than stalling the caller's
196
+ # shutdown, and the credits it holds fall back to server-side expiry.
197
+ def drain(timeout_ms = SHUTDOWN_DRAIN_TIMEOUT_MS)
198
+ deadline = monotonic_ms + timeout_ms
199
+ loop do
200
+ pending_threads, pending_flights = pending_work
201
+ return if pending_threads.empty? && pending_flights.empty?
202
+
203
+ remaining = deadline - monotonic_ms
204
+ if remaining <= 0
205
+ @logger.warn(
206
+ "Timed out after #{timeout_ms}ms draining in-flight credit lease work; " \
207
+ "any credits it holds will be released by server-side expiry"
208
+ )
209
+ return
210
+ end
211
+ # Recomputed per thread, not once for the round: a shared budget
212
+ # spent thread by thread would let N stalled threads wait N times
213
+ # the timeout a caller asked close to take.
214
+ pending_threads.each do |thread|
215
+ left = deadline - monotonic_ms
216
+ break if left <= 0
217
+
218
+ thread.join(left / 1000.0)
219
+ end
220
+ pending_flights.each { |flight| flight.wait(deadline - monotonic_ms) }
221
+ # Settling one round can enqueue another (an acquire that loses its
222
+ # race fires a release), so keep going until nothing is left.
223
+ end
224
+ end
225
+
226
+ # Release every live lease held in the store, returning its unspent
227
+ # remainder now rather than at expiry. ONLY safe per-process: a shared
228
+ # store's siblings are still drawing on those leases, which the list
229
+ # capability check excludes.
230
+ #
231
+ # timeout_ms bounds the whole pass, because each release is a
232
+ # synchronous round trip and a slow API would otherwise stretch close by
233
+ # one timeout per slot. What is left expires server-side, the same
234
+ # outcome a failed release already has.
235
+ def release_all_local_leases(timeout_ms = nil)
236
+ return nil unless @lease_store.respond_to?(:list)
237
+
238
+ # A failed listing is logged and left to server-side expiry: raising
239
+ # here would skip the rest of the caller's close.
240
+ entries = begin
241
+ @lease_store.list
242
+ rescue StandardError => e
243
+ @logger.warn("Failed to list credit leases on close (they will expire server-side): #{e.message}")
244
+ nil
245
+ end
246
+ return nil if entries.nil? || entries.empty?
247
+
248
+ deadline = timeout_ms.nil? ? nil : monotonic_ms + timeout_ms
249
+ entries.each_with_index do |entry, index|
250
+ # Skip expired leases: the server already swept and refunded them.
251
+ next if entry.expired?(@clock.call)
252
+
253
+ if deadline && monotonic_ms >= deadline
254
+ left = entries[index..].count { |remaining| !remaining.expired?(@clock.call) }
255
+ @logger.warn(
256
+ "Timed out after #{timeout_ms.round}ms releasing credit leases on close; " \
257
+ "#{left} left to server-side expiry"
258
+ )
259
+ break
260
+ end
261
+
262
+ begin
263
+ @wire.release(lease_id: entry.lease_id)
264
+ @lease_store.drop(entry.company_id, entry.credit_type_id)
265
+ @logger.debug("Released credit lease #{entry.lease_id} on close")
266
+ rescue StandardError => e
267
+ @logger.warn(
268
+ "Failed to release credit lease #{entry.lease_id} on close " \
269
+ "(it will expire server-side): #{e.message}"
270
+ )
271
+ end
272
+ end
273
+ nil
274
+ end
275
+
276
+ private
277
+
278
+ def acquire(company_id, credit_type_id, request_options)
279
+ resolved = resolve_config(credit_type_id)
280
+ grant = @wire.acquire(
281
+ company_id: company_id,
282
+ credit_type_id: credit_type_id,
283
+ requested_amount: resolved.lease_size,
284
+ expires_at: @clock.call + (resolved.lease_duration_ms / 1000.0),
285
+ request_options: request_options || {}
286
+ )
287
+ wrote = @lease_store.replace(LeaseEntry.new(
288
+ lease_id: grant.lease_id,
289
+ company_id: grant.company_id,
290
+ credit_type_id: grant.credit_type_id,
291
+ granted_amount: grant.granted_amount,
292
+ expires_at: grant.expires_at
293
+ ))
294
+ return @lease_store.get(company_id, credit_type_id) if wrote
295
+
296
+ settle_lost_race(company_id, credit_type_id, grant)
297
+ rescue StandardError => e
298
+ @logger.error("Failed to acquire credit lease for #{company_id}/#{credit_type_id}: #{e.message}")
299
+ nil
300
+ end
301
+
302
+ # A sibling installed a live lease first, so replace kept theirs. The
303
+ # server is idempotent for an active slot, so ours is normally the SAME
304
+ # lease and releasing it would refund a lease every process is still
305
+ # reserving against. Only a DIFFERENT lease is a redundant hold worth
306
+ # releasing. An empty slot (it expired in the gap) is skipped too: that
307
+ # id may well be what the next acquire is handed back.
308
+ def settle_lost_race(company_id, credit_type_id, grant)
309
+ current = @lease_store.get(company_id, credit_type_id)
310
+ if current && current.lease_id != grant.lease_id
311
+ @logger.debug(
312
+ "Lost acquire race for #{company_id}/#{credit_type_id}; releasing redundant lease #{grant.lease_id}"
313
+ )
314
+ # Fire and forget: a failed release just falls back to lease expiry.
315
+ track do
316
+ @wire.release(lease_id: grant.lease_id)
317
+ rescue StandardError => e
318
+ @logger.warn("Failed to release redundant credit lease #{grant.lease_id}: #{e.message}")
319
+ end
320
+ else
321
+ @logger.debug(
322
+ "Lost acquire race for #{company_id}/#{credit_type_id}; " \
323
+ "server returned the installed lease #{grant.lease_id}, nothing to release"
324
+ )
325
+ end
326
+ current
327
+ end
328
+
329
+ # Cheap read-only answer to "would extend_if_needed do anything". It can
330
+ # go stale between here and the thread, which is harmless: the thread
331
+ # re-reads and re-decides under the flight.
332
+ def extend_due?(company_id, credit_type_id, required_credits)
333
+ entry = @lease_store.get(company_id, credit_type_id)
334
+ return false if entry.nil? || entry.expired?(@clock.call)
335
+
336
+ needs_extend?(entry, resolve_config(credit_type_id), required_credits)
337
+ rescue StandardError => e
338
+ # No extend without a reading: a store outage answering true would
339
+ # spawn a thread per check, and each would only fail the same read.
340
+ @logger.debug("Failed to read lease store for #{company_id}/#{credit_type_id}: #{e.message}")
341
+ false
342
+ end
343
+
344
+ # Joins are budgeted, extends of our own are not: a caller may wait out
345
+ # flights that ask for too little, but once the budget runs out it
346
+ # issues its own single extend rather than joining again. Without the
347
+ # budget a caller could wait behind an unbounded run of other callers'
348
+ # follow-ups; without the extend of its own it would return a balance it
349
+ # already knows is short and fail its retry with credits on the server.
350
+ def extend_if_needed(company_id, credit_type_id, required_credits, request_options, deadline = nil)
351
+ # A joiner waits on someone else's wire call, which runs on whatever
352
+ # timeout ITS caller set (a background refresh uses the client
353
+ # default). So the wait is capped at this caller's own deadline: a
354
+ # check with 200ms to spend must not sit behind a 30s extend. A check
355
+ # passes the deadline it fixed when it started, since one taken now
356
+ # would grant a fresh timeout on top of the acquire and reserve.
357
+ deadline ||= join_deadline(request_options)
358
+ joins_left = MAX_EXTEND_JOINS
359
+ loop do
360
+ entry = read_live_lease(company_id, credit_type_id)
361
+ return nil if entry.nil?
362
+
363
+ resolved = resolve_config(credit_type_id)
364
+ return entry unless needs_extend?(entry, resolved, required_credits)
365
+
366
+ # Sized to cover the request that triggered it, or a check needing
367
+ # more than the tranche would fail its post-extend retry forever
368
+ # against an ample server balance. Sized here rather than at the
369
+ # wire call so the flight below and the request body provably carry
370
+ # the same number for a joiner to compare against.
371
+ shortfall = required_credits ? required_credits - entry.local_remaining_credits : 0
372
+ additional_amount = [resolved.lease_size, shortfall].max
373
+
374
+ key = Leases.lease_key(company_id, credit_type_id)
375
+ # With the budget spent we take the slot ourselves rather than
376
+ # joining a flight that has already proved too small.
377
+ flight, leader = enlist(@inflight_extend, key, force: joins_left <= 0) { Flight.new(additional_amount) }
378
+ return log_stopped("extend", company_id, credit_type_id) if flight.nil?
379
+ return run_extend(key, flight, entry, resolved, additional_amount, request_options) if leader
380
+
381
+ # A watermark refresh that finds a flight already running has
382
+ # nothing to wait for: the credits it wants are the ones that call
383
+ # is fetching. Only a caller naming required_credits waits, because
384
+ # it has a reserve to retry.
385
+ return nil if required_credits.nil?
386
+
387
+ joined = join_within(flight, deadline)
388
+ if joined.equal?(JOIN_TIMED_OUT)
389
+ # The flight runs on for everybody else; we just stop waiting on
390
+ # it. Reporting no entry sends the caller down its fail-open or
391
+ # fail-closed path, which is what its timeout asked for.
392
+ @logger.debug(
393
+ "Extend in flight for #{company_id}/#{credit_type_id} outlasted the caller's timeout; " \
394
+ "not waiting on it"
395
+ )
396
+ return nil
397
+ end
398
+ # The flight already asked for at least what we need, which covers
399
+ # every watermark-driven joiner and any check the tranche covers.
400
+ # One wire call serves all of them, which is the point.
401
+ return joined if additional_amount <= flight.requested_additional
402
+
403
+ # It asked for less. Go round again to re-read the slot it just
404
+ # moved, so what we ask for next is sized against the balance it
405
+ # left rather than the one we started from.
406
+ joins_left -= 1
407
+ end
408
+ end
409
+
410
+ # Wait on another caller's acquire, reporting nil once the deadline
411
+ # passes. The acquire runs on for whoever else is on it, and what it
412
+ # installs is there for the next check to read.
413
+ def join_acquire(flight, deadline, company_id, credit_type_id)
414
+ joined = join_within(flight, deadline)
415
+ return joined unless joined.equal?(JOIN_TIMED_OUT)
416
+
417
+ @logger.debug(
418
+ "Acquire in flight for #{company_id}/#{credit_type_id} outlasted the caller's timeout; not waiting on it"
419
+ )
420
+ nil
421
+ end
422
+
423
+ # Read the slot, reporting nil when the read fails or the lease is
424
+ # absent or expired. An expired lease is never extended: the server
425
+ # treats it as released, with its remainder already refunded to the
426
+ # company balance, so the right move is the fresh acquire the next check
427
+ # performs.
428
+ def read_live_lease(company_id, credit_type_id)
429
+ entry = @lease_store.get(company_id, credit_type_id)
430
+ return nil if entry.nil? || entry.expired?(@clock.call)
431
+
432
+ entry
433
+ rescue StandardError => e
434
+ @logger.warn("Failed to read lease store for #{company_id}/#{credit_type_id}: #{e.message}")
435
+ nil
436
+ end
437
+
438
+ # Whether the entry sits low enough to warrant an extend.
439
+ def needs_extend?(entry, resolved, required_credits)
440
+ ratio = entry.local_remaining_credits / [entry.granted_amount, 1].max
441
+ below_watermark = ratio <= resolved.low_water_mark
442
+ below_required = !required_credits.nil? && entry.local_remaining_credits < required_credits
443
+ below_watermark || below_required
444
+ end
445
+
446
+ # When a joiner's wait on a shared flight runs out, or nil for no cap.
447
+ def join_deadline(request_options)
448
+ Leases.join_deadline(request_options)
449
+ end
450
+
451
+ # Wait out a flight somebody else is running, giving up at the deadline.
452
+ # Giving up abandons only our wait: the flight keeps running for the
453
+ # callers still on it, and whatever it installs is there for our next
454
+ # check to read.
455
+ def join_within(flight, deadline)
456
+ return flight.wait if deadline.nil?
457
+
458
+ remaining = deadline - monotonic_ms
459
+ return JOIN_TIMED_OUT if remaining <= 0
460
+
461
+ joined = flight.wait(remaining)
462
+ flight.done? ? joined : JOIN_TIMED_OUT
463
+ end
464
+
465
+ # Run the extend as the slot's in-flight one. The cleanup is
466
+ # identity-guarded rather than an unconditional delete: a joiner whose
467
+ # shortfall outran this flight registers a follow-up for the same key,
468
+ # and this flight must not evict it.
469
+ def run_extend(key, flight, entry, resolved, additional_amount, request_options)
470
+ result = extend(entry, resolved, additional_amount, request_options)
471
+ result
472
+ ensure
473
+ @flight_mutex.synchronize { @inflight_extend.delete(key) if @inflight_extend[key].equal?(flight) }
474
+ flight.complete(result)
475
+ end
476
+
477
+ def extend(entry, resolved, additional_amount, request_options)
478
+ grant = @wire.extend(
479
+ lease_id: entry.lease_id,
480
+ additional_amount: additional_amount,
481
+ expires_at: @clock.call + (resolved.lease_duration_ms / 1000.0),
482
+ # Minted once per extend, outside the wire call, so the transport's
483
+ # retries resend the same key: a retry after a lost 2xx is handed
484
+ # the lease as it stands instead of growing it a second time.
485
+ idempotency_key: SecureRandom.uuid,
486
+ request_options: request_options || {}
487
+ )
488
+ # Reconciled to the server's TOTAL, with the store computing the delta
489
+ # against its own current figure rather than the read above: two
490
+ # sibling processes extending one shared lease would each apply a
491
+ # stale-read delta and mint phantom credits. Pinned to the lease the
492
+ # server extended, so an expiry mid-call drops the delta instead of
493
+ # minting it onto the successor.
494
+ @lease_store.extend(entry.company_id, entry.credit_type_id, grant.granted_amount, grant.expires_at,
495
+ entry.lease_id)
496
+ @logger.debug(
497
+ "Extended credit lease #{entry.lease_id} to #{grant.granted_amount} " \
498
+ "(was #{entry.granted_amount} at last read)"
499
+ )
500
+ @lease_store.get(entry.company_id, entry.credit_type_id)
501
+ rescue StandardError => e
502
+ @logger.warn("Failed to extend credit lease #{entry.lease_id}: #{e.message}")
503
+ nil
504
+ end
505
+
506
+ # Register this caller against the slot's in-flight call, or become the
507
+ # one that makes it. Returns [flight, leader], or [nil, false] once the
508
+ # manager is stopped: the stopped read happens here, under the same lock
509
+ # stop takes, because an unguarded read outside it can be overtaken by a
510
+ # close whose drain then never sees the flight this would have made.
511
+ def enlist(flights, key, force: false)
512
+ @flight_mutex.synchronize do
513
+ next [nil, false] if @stopped
514
+
515
+ existing = flights[key]
516
+ next [existing, false] if existing && !force
517
+
518
+ flight = yield
519
+ flights[key] = flight
520
+ [flight, true]
521
+ end
522
+ end
523
+
524
+ # Whether the slot already has an extend on the wire.
525
+ def extend_in_flight?(company_id, credit_type_id)
526
+ @flight_mutex.synchronize { !@inflight_extend[Leases.lease_key(company_id, credit_type_id)].nil? }
527
+ end
528
+
529
+ # Hold a reference to work nobody joins so drain can wait it out.
530
+ def track(&block)
531
+ thread = Thread.new do
532
+ block.call
533
+ rescue StandardError => e
534
+ @logger.warn("Background credit lease work failed: #{e.message}")
535
+ nil
536
+ end
537
+ thread.abort_on_exception = false
538
+ @flight_mutex.synchronize do
539
+ # Finished work is dropped as new work arrives, so the list tracks
540
+ # what is still pending rather than growing for the process's life.
541
+ @background.select!(&:alive?)
542
+ @background << thread
543
+ end
544
+ thread
545
+ end
546
+
547
+ def pending_work
548
+ @flight_mutex.synchronize do
549
+ @background.select!(&:alive?)
550
+ [@background.dup, (@inflight_acquire.values + @inflight_extend.values).reject(&:done?)]
551
+ end
552
+ end
553
+
554
+ def log_stopped(action, company_id, credit_type_id)
555
+ @logger.debug("Lease manager is stopped; skipping #{action} for #{company_id}/#{credit_type_id}")
556
+ nil
557
+ end
558
+
559
+ def monotonic_ms
560
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
561
+ end
562
+ end
563
+ end
564
+ end
565
+ end