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
@@ -59,6 +59,24 @@ module Schematic
59
59
  # Optional event metadata accepted via the `options:` keyword on track/identify.
60
60
  # identify only honors :idempotency_key; track also honors :sent_at,
61
61
  # :trusted_client_clock, and :backfill. Fields are only sent when set.
62
+ # Namespaces the idempotency key on the track event a reservation settles
63
+ # into. Deterministic per reservation, so a recovery emit (the work outlived
64
+ # the local reservation TTL) and an accidental double settle collapse to one
65
+ # billed event: the pipeline drops duplicates for 24h before any credit
66
+ # consumption runs.
67
+ RESERVATION_TRACK_IDEMPOTENCY_PREFIX = "lease-reservation:"
68
+
69
+ # The prefix Schematic's secure company ids carry, whatever key name they
70
+ # are passed under.
71
+ COMPANY_ID_PREFIX = "comp_"
72
+
73
+ # Knobs that only steer the local lease plumbing, which server mode never
74
+ # builds. Setting one there does nothing, so the client says so at startup.
75
+ CLIENT_ONLY_LEASE_OPTIONS = %i[
76
+ default_lease_duration default_lease_size low_water_mark sweep_interval_ms
77
+ redis_client redis_key_prefix prewarm_resolve_timeout_ms overrides
78
+ ].freeze
79
+
62
80
  TRACK_OPTION_KEYS = %i[idempotency_key sent_at trusted_client_clock backfill].freeze
63
81
  IDENTIFY_OPTION_KEYS = %i[idempotency_key].freeze
64
82
 
@@ -72,6 +90,7 @@ module Schematic
72
90
  event_capture_base_url: nil,
73
91
  use_data_stream: false,
74
92
  datastream_options: {},
93
+ credit_leases: nil,
75
94
  logger: nil,
76
95
  log_level: :warn
77
96
  )
@@ -89,6 +108,13 @@ module Schematic
89
108
  end
90
109
  @offline = offline
91
110
 
111
+ # Validated here rather than alongside the rest of the lease setup below:
112
+ # a rejected knob raises out of the constructor, so the caller never gets
113
+ # a client to close, and anything already running by then (the event
114
+ # buffer's flush thread, the DataStream socket) is leaked for the life of
115
+ # the process. Nothing has started yet at this point.
116
+ validated_leases = validated_credit_lease_config(credit_leases)
117
+
92
118
  # Initialize Fern-generated API client
93
119
  @api_client = if @offline
94
120
  nil
@@ -118,6 +144,25 @@ module Schematic
118
144
  @rules_engine = nil
119
145
  setup_datastream(datastream_options) if use_data_stream && !@offline
120
146
 
147
+ # Credit lease + reservation plumbing, if the caller opted in.
148
+ @credit_lease_config = nil
149
+ @credit_lease_mode = nil
150
+ @credit_lease_manager = nil
151
+ @lease_store = nil
152
+ @reservations = nil
153
+ # True when lease state lives in a shared backend that sibling processes
154
+ # may also be drawing on. close must then NOT release leases.
155
+ @lease_backend_shared = false
156
+ @server_reservation_ttl_ms = Credits::Leases::DEFAULT_RESERVATION_TTL_MS
157
+ @prewarm_resolve_timeout_ms = Credits::Leases::DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS
158
+ # Prewarms identify spawned and nobody joins. close waits them out: an
159
+ # acquire that lands after the release installs a lease nothing releases,
160
+ # and its credits stay held until the server expires them.
161
+ @pending_prewarms = []
162
+ @pending_prewarms_mutex = Mutex.new
163
+ @closing = false
164
+ setup_credit_leases(validated_leases, datastream_options) if credit_leases
165
+
121
166
  # Register shutdown hook to ensure graceful cleanup on process exit
122
167
  at_exit { close }
123
168
  end
@@ -128,11 +173,19 @@ module Schematic
128
173
  check_flag_with_entitlement(flag_key, company: company, user: user).value
129
174
  end
130
175
 
131
- def check_flag_with_entitlement(flag_key, company: nil, user: nil)
176
+ # default_value overrides the registered flag default on every path that
177
+ # cannot answer from the flag itself: offline, an API error, or a response
178
+ # with no value. Leaving it nil keeps the registered default. timeout_ms is
179
+ # threaded to the API call, but see the note on check: the generated
180
+ # transport does not yet apply a per-request timeout.
181
+ def check_flag_with_entitlement(flag_key, company: nil, user: nil, preflight: nil, default_value: nil,
182
+ timeout_ms: nil)
183
+ get_default = -> { resolve_default_value(flag_key, default_value) }
184
+
132
185
  # Offline mode
133
186
  if @offline
134
187
  return CheckFlagResponse.new(
135
- value: get_flag_default(flag_key),
188
+ value: get_default.call,
136
189
  flag_key: flag_key,
137
190
  reason: "offline mode"
138
191
  )
@@ -142,8 +195,22 @@ module Schematic
142
195
  if @datastream_client&.connected?
143
196
  begin
144
197
  eval_ctx = build_eval_context(company, user)
145
- result = @datastream_client.check_flag(eval_ctx, flag_key)
146
-
198
+ # Only widen the call when there is something to pass: a DataStream
199
+ # double written against the two-argument form still works, and the
200
+ # preflight envelope reaches the engine when a lease check supplies it.
201
+ result = if preflight.nil?
202
+ @datastream_client.check_flag(eval_ctx, flag_key)
203
+ else
204
+ @datastream_client.check_flag(eval_ctx, flag_key, preflight)
205
+ end
206
+
207
+ # A nil value is the engine declining to answer, not a false. The
208
+ # caller's default stands in, falling back to the registered one,
209
+ # which is what CheckFlagResponse's own coercion to false would
210
+ # otherwise hide. Resolved the same way the offline and API paths
211
+ # resolve it, or one call would honour default_value and another
212
+ # would not.
213
+ result[:value] = get_default.call if result[:value].nil?
147
214
  response = CheckFlagResponse.new(result)
148
215
  enqueue_flag_check_event(flag_key, response, company, user)
149
216
  return response
@@ -155,11 +222,12 @@ module Schematic
155
222
  end
156
223
 
157
224
  # API path with caching
158
- check_flag_via_api(flag_key, company, user)
225
+ check_flag_via_api(flag_key, company, user, preflight: preflight, timeout_ms: timeout_ms,
226
+ get_default: get_default)
159
227
  rescue StandardError => e
160
228
  @logger.error("check_flag_with_entitlement error for '#{flag_key}': #{e.message}")
161
229
  CheckFlagResponse.new(
162
- value: get_flag_default(flag_key),
230
+ value: get_default.call,
163
231
  flag_key: flag_key,
164
232
  reason: "error: #{e.message}"
165
233
  )
@@ -255,29 +323,216 @@ module Schematic
255
323
  end
256
324
  end
257
325
 
258
- # --- Event Submission ---
326
+ # --- Credit-aware Flag Checking ---
327
+
328
+ # Credit-aware feature check. With credit_leases configured and a usage
329
+ # passed (optionally qualified by an event_subtype), this gates the check
330
+ # against the company's credit balance and returns a reservation handle on
331
+ # success. Hand that handle to track_with_reservation when the work
332
+ # completes.
333
+ #
334
+ # In client mode (DataStream enabled) the hold is carved out of a local
335
+ # lease and the flag is evaluated by the WASM engine. In server mode it is a
336
+ # single check-and-reserve API call that evaluates the flag and takes the
337
+ # hold server-side. credit_leases[:mode] picks; the default, :auto, uses
338
+ # client mode when DataStream is enabled and server mode otherwise.
339
+ #
340
+ # Without credit_leases (or without a usage) this falls through to a plain
341
+ # flag check and returns a result with no reservation. The caller's
342
+ # preflight is still threaded through that plain check, so any client-side
343
+ # evaluation path gates on the post-call balance, just without a
344
+ # reservation, and the REST path sends the preflight too. default_value
345
+ # governs that fallback too, so a check that cannot reach the credit path
346
+ # still answers the way the caller asked.
347
+ #
348
+ # timeout_ms is carried on the API request but not yet applied: the
349
+ # generated transport takes its timeout from the construction of its HTTP
350
+ # client, and nothing exposes that, so no per-check or client-level timeout
351
+ # is configurable today.
352
+ def check(flag_key, company: nil, user: nil, usage: nil, event_subtype: nil, on_acquire_failure: nil,
353
+ default_value: nil, timeout_ms: nil)
354
+ options = {
355
+ usage: usage,
356
+ event_subtype: event_subtype,
357
+ on_acquire_failure: on_acquire_failure,
358
+ default_value: default_value,
359
+ timeout_ms: timeout_ms
360
+ }
361
+ eval_ctx = build_eval_context(company, user)
362
+ fallback = -> { plain_check_result(flag_key, company, user, options) }
363
+
364
+ mode = effective_lease_mode
365
+ # With gating configured, the lease and server paths own a malformed
366
+ # usage and refuse it by the caller's failure mode. With no gating there
367
+ # is nothing to refuse: the value would only reach the preflight builder,
368
+ # which the engine and the REST body both take as an integer, so drop it
369
+ # and ask the plain question. Ruby has no type to catch it at the boundary
370
+ # the way the other SDKs do.
371
+ if mode.nil? && !usage.nil? && !Credits::Leases.valid_quantity?(usage)
372
+ @logger.warn(
373
+ "check: invalid usage #{usage.inspect} for flag #{flag_key}, must be a finite non-negative " \
374
+ "number; continuing without one"
375
+ )
376
+ options[:usage] = nil
377
+ usage = nil
378
+ end
379
+ return fallback.call if usage.nil? || mode.nil?
380
+
381
+ if mode == :server
382
+ return Credits::Leases.check_with_server_reservation(
383
+ Credits::Leases::ServerCheckDeps.new(
384
+ features: features, credits: credits, logger: @logger,
385
+ reservation_ttl_ms: @server_reservation_ttl_ms,
386
+ default_value: -> { resolve_default_value(flag_key, default_value) }
387
+ ),
388
+ flag_key, eval_ctx, options, &fallback
389
+ )
390
+ end
259
391
 
260
- def identify(body, options: nil)
392
+ # Client mode without the local plumbing (mode: :client and no DataStream)
393
+ # keeps the old behavior: a plain, ungated flag check.
394
+ return fallback.call unless @credit_lease_manager && @lease_store && @reservations
395
+
396
+ Credits::Leases.check_with_lease(
397
+ Credits::Leases::CheckDeps.new(
398
+ lease_store: @lease_store, reservations: @reservations, manager: @credit_lease_manager,
399
+ datastream: @datastream_client, logger: @logger,
400
+ # Lease-path checks must stay visible to flag-check analytics and
401
+ # company last-seen, the same as every plain check path.
402
+ enqueue_flag_check_event: ->(body) { enqueue_lease_flag_check_event(body) }
403
+ ),
404
+ flag_key, eval_ctx, options, &fallback
405
+ )
406
+ end
407
+
408
+ # Consume a reservation issued by check. Refunds the unused slice back to
409
+ # the lease's local balance and enqueues a track event with the actual
410
+ # quantity; the server-side event processor consumes
411
+ # actual_quantity x consumption_rate from the company's real credit balance.
412
+ #
413
+ # A server-mode handle has no local hold to refund: the track event carries
414
+ # the reservation id, and the server settles the hold when it processes the
415
+ # event.
416
+ #
417
+ # If the work outlived the reservation's TTL and the sweeper already
418
+ # returned the hold to the lease, the local refund has happened but the
419
+ # usage must still be billed, so the track is emitted anyway as a recovery
420
+ # emit. Double billing is prevented server-side: the track carries a
421
+ # deterministic idempotency key derived from the reservation id, and the
422
+ # events pipeline drops duplicates for 24h before any credit consumption
423
+ # runs. So a recovery emit racing the normal emit, or an accidental second
424
+ # settle, collapses to a single billed event, across processes and restarts.
425
+ def track_with_reservation(reservation, actual_quantity, traits: nil)
261
426
  return if @offline
262
427
 
263
- @event_buffer.push(build_event("identify", body, options, IDENTIFY_OPTION_KEYS))
264
- rescue StandardError => e
265
- @logger.error("Error sending identify event: #{e.message}")
428
+ # check allows without a hold in several ordinary cases: the feature is
429
+ # not credit-metered, the check failed open, usage was 0, or credit leases
430
+ # are not configured. Callers pass result.reservation straight through, so
431
+ # take the nil and tell them how to bill the usage instead of raising on a
432
+ # settle that has nothing to settle.
433
+ if reservation.nil?
434
+ @logger.error(
435
+ "track_with_reservation called without a reservation: the check allowed without taking a hold, " \
436
+ "so there is nothing to settle. Report the usage with track instead."
437
+ )
438
+ return
439
+ end
440
+
441
+ # Mirror the check-path usage guard: a non-finite quantity must reach
442
+ # neither the store (clamping against NaN claims the reservation with NO
443
+ # refund of the unspent slice) nor the billing event, and a negative one
444
+ # would bill negative usage. Skipping the settle leaves the reservation to
445
+ # expire at its TTL, where the sweeper refunds the full hold, so no
446
+ # credits are lost and nothing bogus is billed.
447
+ unless Credits::Leases.valid_quantity?(actual_quantity)
448
+ @logger.error(
449
+ "track_with_reservation: invalid actual_quantity #{actual_quantity.inspect} for reservation " \
450
+ "#{reservation.id}, must be a finite non-negative number; skipping settle " \
451
+ "(the hold is refunded at its TTL)"
452
+ )
453
+ return
454
+ end
455
+
456
+ settle_reservation(reservation, actual_quantity, traits)
266
457
  end
267
458
 
268
- def track(body, options: nil)
459
+ # Pre-warm a credit lease for each given credit type id, so the first check
460
+ # against it does not pay the acquire round trip. Failures are logged, never
461
+ # raised.
462
+ #
463
+ # When the company carries only secondary keys (no id), prewarm actively
464
+ # fetches it over the datastream, waiting up to
465
+ # credit_leases[:prewarm_resolve_timeout_ms], which both resolves the id and
466
+ # warms the cache so the first check hits the lease path.
467
+ def prewarm(credit_type_ids, company: nil)
468
+ if @credit_lease_manager.nil? || @lease_store.nil?
469
+ @logger.debug(
470
+ effective_lease_mode == :server ? "prewarm is a no-op in server mode, there is no local lease to warm" : "prewarm called but credit_leases is not configured"
471
+ )
472
+ return
473
+ end
474
+ if company.nil? || company.empty?
475
+ @logger.debug("prewarm requires a company")
476
+ return
477
+ end
478
+ # Documented as never raising, and a caller reading ids out of config can
479
+ # hand over nil or an empty list without meaning to.
480
+ if credit_type_ids.nil? || credit_type_ids.empty?
481
+ @logger.debug("prewarm requires at least one credit type id")
482
+ return
483
+ end
484
+ if @closing
485
+ # close only waits out the prewarms it spawned; a caller invoking
486
+ # prewarm directly would otherwise install a lease after the release has
487
+ # already listed the store.
488
+ @logger.debug("prewarm: client is closing, skipping acquire")
489
+ return
490
+ end
491
+
492
+ company_id = resolve_company_id_with_wait(company)
493
+ if company_id.nil?
494
+ @logger.debug(
495
+ "prewarm: company not resolved within #{@prewarm_resolve_timeout_ms}ms for keys #{company} " \
496
+ "(first check will acquire)"
497
+ )
498
+ return
499
+ end
500
+
501
+ credit_type_ids.each do |credit_type_id|
502
+ @credit_lease_manager.acquire_if_needed(company_id, credit_type_id)
503
+ rescue StandardError => e
504
+ @logger.warn("prewarm: failed to acquire lease for #{credit_type_id}: #{e.message}")
505
+ end
506
+ nil
507
+ end
508
+
509
+ # --- Event Submission ---
510
+
511
+ # prewarm names credit type ids to acquire leases for in the background once
512
+ # the identify event is enqueued. Failures never surface to the caller, and
513
+ # it is a no-op unless credit_leases is configured.
514
+ def identify(body, options: nil, prewarm: nil)
269
515
  return if @offline
270
516
 
271
- @event_buffer.push(build_event("track", body, options, TRACK_OPTION_KEYS))
517
+ begin
518
+ @event_buffer.push(build_event("identify", body, options, IDENTIFY_OPTION_KEYS))
519
+ rescue StandardError => e
520
+ @logger.error("Error sending identify event: #{e.message}")
521
+ end
272
522
 
273
- # Update company metrics locally if DataStream is active and connected
274
- if @datastream_client&.connected? && body[:company]
275
- event_name = body[:event] || body["event"]
276
- quantity = body[:quantity] || body["quantity"] || 1
277
- @datastream_client.update_company_metrics(body[:company], event_name, quantity)
523
+ return if prewarm.nil? || prewarm.empty?
524
+
525
+ begin
526
+ prewarm_after_identify(body, prewarm)
527
+ rescue StandardError => e
528
+ # identify never raises into its caller, and a prewarm is the least of
529
+ # the reasons it should start.
530
+ @logger.warn("identify prewarm setup failed: #{e.message}")
278
531
  end
279
- rescue StandardError => e
280
- @logger.error("Error sending track event: #{e.message}")
532
+ end
533
+
534
+ def track(body, options: nil)
535
+ emit_track(body, options, update_metrics: true)
281
536
  end
282
537
 
283
538
  # --- Flag Defaults ---
@@ -370,12 +625,27 @@ module Schematic
370
625
 
371
626
  # --- Lifecycle ---
372
627
 
628
+ # Credit leases: with the per-process in-memory backend this process is the
629
+ # only holder of its leases, so they are released here (best-effort), which
630
+ # returns their unspent remainder to the company balance immediately instead
631
+ # of waiting out the lease expiry. With a shared backend, leases are
632
+ # deliberately NOT released: one row per company and credit is shared across
633
+ # every SDK instance pointed at that backend, so a single process shutting
634
+ # down must not release a lease its siblings are still drawing on. Shared
635
+ # leases reclaim themselves by expiring or being fully consumed.
636
+ #
637
+ # Lease work already in flight is waited out, bounded, before the release,
638
+ # so an acquire that lands mid-shutdown is one the release can see.
373
639
  def close
374
640
  return if @closed
375
641
 
376
642
  @closed = true
377
- @event_buffer.stop
643
+ @closing = true
644
+ shut_down_credit_leases
645
+ # DataStream first, then the buffer: its stop flushes, and a flush is the
646
+ # last thing that should still be running.
378
647
  @datastream_client&.close
648
+ @event_buffer.stop
379
649
  @flag_check_cache_providers.each { |c| c.stop if c.respond_to?(:stop) }
380
650
  @logger.debug("SchematicClient closed")
381
651
  end
@@ -393,14 +663,20 @@ module Schematic
393
663
  CheckFlagResponse.new(cached)
394
664
  end
395
665
 
396
- def check_flag_via_api(flag_key, company, user)
397
- # Check cache
666
+ def check_flag_via_api(flag_key, company, user, preflight: nil, timeout_ms: nil, get_default: nil)
667
+ get_default ||= -> { get_flag_default(flag_key) }
398
668
  cache_key = build_cache_key(flag_key, company, user)
399
- @flag_check_cache_providers.each do |provider|
400
- cached = coerce_cached_response(provider.get(cache_key))
401
- if cached
402
- @logger.debug("Flag '#{flag_key}' found in cache (value=#{cached.value})")
403
- return cached
669
+ # The cache is keyed by flag, company and user, so a preflighted check and
670
+ # a plain one collide on one entry while asking different questions ("is
671
+ # this allowed after the action" versus "is it allowed now"). A
672
+ # preflighted check therefore neither reads the cache nor writes to it.
673
+ if preflight.nil?
674
+ @flag_check_cache_providers.each do |provider|
675
+ cached = coerce_cached_response(provider.get(cache_key))
676
+ if cached
677
+ @logger.debug("Flag '#{flag_key}' found in cache (value=#{cached.value})")
678
+ return cached
679
+ end
404
680
  end
405
681
  end
406
682
 
@@ -410,11 +686,19 @@ module Schematic
410
686
  eval_body = {}
411
687
  eval_body[:company] = company if company&.any?
412
688
  eval_body[:user] = user if user&.any?
689
+ eval_body[:preflight] = preflight if preflight
413
690
 
414
- api_response = @api_client.features.check_flag(key: flag_key, **eval_body)
691
+ api_response = @api_client.features.check_flag(
692
+ request_options: api_request_options(timeout_ms), key: flag_key, **eval_body
693
+ )
415
694
  data = api_response.data
416
695
  @logger.debug("API returned flag '#{flag_key}' value=#{data.value}, reason=#{data.reason}")
417
696
 
697
+ if data.value.nil?
698
+ @logger.debug("No value returned from feature flag API for flag '#{flag_key}', falling back to default")
699
+ return CheckFlagResponse.new(value: get_default.call, flag_key: flag_key, reason: "flag default")
700
+ end
701
+
418
702
  response = CheckFlagResponse.new(
419
703
  value: data.value,
420
704
  flag_key: data.flag,
@@ -433,22 +717,35 @@ module Schematic
433
717
  feature_usage_reset_at: data.respond_to?(:feature_usage_reset_at) ? data.feature_usage_reset_at : nil
434
718
  )
435
719
 
436
- # Cache the response
437
- @flag_check_cache_providers.each do |provider|
438
- provider.set(cache_key, response)
720
+ # Cache the response, unless the verdict was preflighted: it answers a
721
+ # question a later plain check is not asking.
722
+ if preflight.nil?
723
+ @flag_check_cache_providers.each do |provider|
724
+ provider.set(cache_key, response)
725
+ end
439
726
  end
440
727
 
441
728
  response
442
729
  rescue StandardError => e
443
730
  @logger.error("API flag check failed for '#{flag_key}': #{e.message}")
444
731
  CheckFlagResponse.new(
445
- value: get_flag_default(flag_key),
732
+ value: get_default.call,
446
733
  flag_key: flag_key,
447
734
  reason: "error: #{e.message}"
448
735
  )
449
736
  end
450
737
  end
451
738
 
739
+ # The generated transport reads its timeout from its own construction, not
740
+ # from a request, so timeout_in_seconds is carried but not yet honored.
741
+ # Sending it anyway means a per-check timeout starts working the moment the
742
+ # transport does, without another change here.
743
+ def api_request_options(timeout_ms)
744
+ return {} if timeout_ms.nil?
745
+
746
+ { timeout_in_seconds: timeout_ms / 1000.0 }
747
+ end
748
+
452
749
  def get_flag_default(flag_key)
453
750
  @flag_defaults_mutex.synchronize do
454
751
  @flag_defaults.fetch(flag_key, false)
@@ -557,6 +854,506 @@ module Schematic
557
854
  })
558
855
  end
559
856
 
857
+ # --- Credit Leases ---
858
+
859
+ # Normalize and validate before the constructor starts anything, so a bad
860
+ # knob is a clean raise rather than a raise on top of a running flush thread
861
+ # and an open socket. Offline is left alone: it starts neither, and the
862
+ # warning below already says lease gating is off.
863
+ def validated_credit_lease_config(config)
864
+ return nil if config.nil? || @offline
865
+
866
+ normalize_credit_lease_config(config).tap { |normalized| validate_credit_lease_config(normalized) }
867
+ end
868
+
869
+ def setup_credit_leases(config, datastream_options)
870
+ if @offline
871
+ @logger.warn(
872
+ "credit_leases is configured but the client is in offline mode; lease-gated checks are disabled " \
873
+ "and check will return flag defaults with no credit gating."
874
+ )
875
+ return
876
+ end
877
+
878
+ # Already normalized and validated in the constructor.
879
+ @credit_lease_config = config
880
+ @credit_lease_mode = config[:mode] || :auto
881
+ resolve_server_reservation_ttl(config)
882
+ warn_about_mode(config)
883
+ return unless credit_lease_mode_uses_leases?
884
+
885
+ build_lease_plumbing(config, datastream_options)
886
+ end
887
+
888
+ # Reject a knob that cannot mean anything, at construction, where the stack
889
+ # still points at the caller. Left to run, a zero sweep interval spins a
890
+ # thread flat out, a non-positive lease size or duration acquires a lease
891
+ # nothing can reserve against, and a water mark outside (0, 1) either never
892
+ # extends or extends on every check.
893
+ def validate_credit_lease_config(config)
894
+ %i[default_lease_duration default_reservation_ttl default_lease_size sweep_interval_ms
895
+ prewarm_resolve_timeout_ms].each do |knob|
896
+ # prewarm_resolve_timeout_ms documents 0 as cache-only, so it alone may
897
+ # be zero.
898
+ validate_positive_number(config, knob, allow_zero: knob == :prewarm_resolve_timeout_ms)
899
+ end
900
+ validate_low_water_mark(config)
901
+ (config[:overrides] || {}).each_value { |override| validate_credit_lease_config(override) }
902
+ nil
903
+ end
904
+
905
+ def validate_positive_number(config, knob, allow_zero: false)
906
+ value = config[knob]
907
+ return if value.nil?
908
+
909
+ # finite? rejects NaN and both infinities, neither of which can size a
910
+ # lease, a sweep, or a timeout.
911
+ valid = value.is_a?(Numeric) && value.to_f.finite? && (allow_zero ? value >= 0 : value.positive?)
912
+ return if valid
913
+
914
+ raise ArgumentError,
915
+ "credit_leases[:#{knob}] must be a finite #{allow_zero ? "non-negative" : "positive"} number, " \
916
+ "got #{value.inspect}"
917
+ end
918
+
919
+ def validate_low_water_mark(config)
920
+ value = config[:low_water_mark]
921
+ return if value.nil?
922
+ return if value.is_a?(Numeric) && value.to_f.finite? && value.positive? && value < 1
923
+
924
+ raise ArgumentError, "credit_leases[:low_water_mark] must be a number between 0 and 1, got #{value.inspect}"
925
+ end
926
+
927
+ # Accept the hyphenated spellings the other SDKs use for the two enum-ish
928
+ # knobs, so one config shape travels across a mixed fleet.
929
+ def normalize_credit_lease_config(config)
930
+ normalized = config.transform_keys(&:to_sym)
931
+ normalized[:mode] = resolve_credit_lease_mode(normalized[:mode])
932
+ overrides = normalized[:overrides]
933
+ return normalized unless overrides.is_a?(Hash)
934
+
935
+ # A credit type id is a string, but { "ct_1": {} } in Ruby is the symbol
936
+ # :ct_1, and the knobs inside are read by symbol. Settle both spellings
937
+ # here: read the wrong way round, an override silently falls back to the
938
+ # client-wide defaults.
939
+ normalized[:overrides] = overrides.each_with_object({}) do |(credit_type_id, knobs), out|
940
+ out[credit_type_id.to_s] = knobs.is_a?(Hash) ? knobs.transform_keys(&:to_sym) : knobs
941
+ end
942
+ normalized
943
+ end
944
+
945
+ # An unrecognized mode must not read as :auto in silence: the branches test
946
+ # for :client and :server and everything else falls through, so a typo would
947
+ # quietly pick a mode the caller did not ask for. Name the value, then use
948
+ # the documented default.
949
+ def resolve_credit_lease_mode(value)
950
+ return :auto if value.nil?
951
+
952
+ mode = Credits::Leases.normalize_symbol(value)
953
+ return mode if Credits::Leases::MODES.include?(mode)
954
+
955
+ @logger.warn(
956
+ "Unrecognized credit_leases[:mode] #{value.inspect}; expected one of " \
957
+ "#{Credits::Leases::MODES.join(", ")}. Using :auto."
958
+ )
959
+ :auto
960
+ end
961
+
962
+ # The API refuses a hold expiring more than an hour after its own clock, and
963
+ # this TTL is applied to the caller's, so clamp a step below the cap to leave
964
+ # room for skew. Only server mode sends the value to the API: in client mode
965
+ # it sizes the local sweep, so clamping there would shorten holds for no
966
+ # reason and the warning would be untrue.
967
+ def resolve_server_reservation_ttl(config)
968
+ configured = config[:default_reservation_ttl] || Credits::Leases::DEFAULT_RESERVATION_TTL_MS
969
+ max_ttl = Credits::Leases::MAX_RESERVATION_TTL_MS - Credits::Leases::RESERVATION_TTL_SKEW_ALLOWANCE_MS
970
+ @server_reservation_ttl_ms = @credit_lease_mode == :client ? configured : [configured, max_ttl].min
971
+ return unless @credit_lease_mode != :client && configured > max_ttl
972
+
973
+ @logger.warn(
974
+ "credit_leases[:default_reservation_ttl] of #{configured}ms is longer than the API will hold credits " \
975
+ "for; server-mode holds will be clamped to #{max_ttl}ms (the " \
976
+ "#{Credits::Leases::MAX_RESERVATION_TTL_MS}ms maximum, less " \
977
+ "#{Credits::Leases::RESERVATION_TTL_SKEW_ALLOWANCE_MS}ms of room for clock skew)."
978
+ )
979
+ end
980
+
981
+ def warn_about_mode(config)
982
+ # Server mode holds credits over the API, so none of the local lease
983
+ # plumbing is built and options that only steer it would silently do
984
+ # nothing. Say so once, at startup. :auto with no DataStream lands in
985
+ # server mode too, and is the likelier way to get here.
986
+ if @credit_lease_mode == :server || (@credit_lease_mode == :auto && @datastream_client.nil?)
987
+ client_only = CLIENT_ONLY_LEASE_OPTIONS.reject { |name| config[name].nil? }
988
+ if client_only.any?
989
+ @logger.warn(
990
+ "credit_leases resolves to server mode, so #{client_only.join(", ")} will be ignored: " \
991
+ "those options only apply to client mode (local leases over DataStream)."
992
+ )
993
+ end
994
+ end
995
+
996
+ # :auto with no DataStream is the server-mode default, not a
997
+ # misconfiguration: check-and-reserve gates over the API instead. :client
998
+ # without DataStream is the degraded path, where every check falls back to
999
+ # a plain flag check with usage ignored, so it warns.
1000
+ if @credit_lease_mode == :auto && @datastream_client.nil?
1001
+ @logger.info(
1002
+ "credit_leases is configured and DataStream is not enabled; credit reservations will run in server " \
1003
+ "mode (one check-and-reserve API call per check). Set use_data_stream: true (or replicator mode) " \
1004
+ "for client-side leases."
1005
+ )
1006
+ end
1007
+ return unless @credit_lease_mode == :client && @datastream_client.nil?
1008
+
1009
+ @logger.warn(
1010
+ "credit_leases is configured but DataStream is not enabled; check will fall back to plain flag checks " \
1011
+ "with NO credit gating (usage is ignored). Set use_data_stream: true (or replicator mode) to enable " \
1012
+ "lease-gated checks."
1013
+ )
1014
+ end
1015
+
1016
+ def build_lease_plumbing(config, datastream_options)
1017
+ sweep_ms = config[:sweep_interval_ms] || Credits::Leases::DEFAULT_SWEEP_INTERVAL_MS
1018
+ # Lease and reservation state belongs in a shared cache so gating holds
1019
+ # across horizontally scaled processes. Prefer an explicit client, but
1020
+ # otherwise reuse the one the DataStream cache is already configured with,
1021
+ # so an existing Redis setup backs leases automatically. Same for the key
1022
+ # prefix.
1023
+ redis_client = config[:redis_client] || datastream_options[:redis_client]
1024
+ key_prefix = config[:redis_key_prefix] || datastream_options[:redis_key_prefix]
1025
+
1026
+ if redis_client
1027
+ # Shared-state backend: the lease balance and the reservation table live
1028
+ # in Redis, and the Lua-driven reserve and consume paths give atomic
1029
+ # cross-process gating without a separate lock service.
1030
+ @lease_backend_shared = true
1031
+ @lease_store = Credits::Leases::RedisLeaseStore.new(
1032
+ client: redis_client, key_prefix: key_prefix,
1033
+ default_lease_duration_ms: config[:default_lease_duration] || Credits::Leases::DEFAULT_LEASE_DURATION_MS
1034
+ )
1035
+ @reservations = Credits::Leases::RedisReservationStore.new(
1036
+ client: redis_client, lease_store: @lease_store, sweep_interval_ms: sweep_ms,
1037
+ key_prefix: key_prefix, logger: @logger
1038
+ )
1039
+ else
1040
+ # No shared backend configured. In a horizontally scaled deployment each
1041
+ # process then acquires and gates against its own leases, which defeats
1042
+ # the cross-process over-spend protection that is the point of leasing,
1043
+ # so warn rather than degrade silently.
1044
+ @logger.warn(
1045
+ "credit_leases is enabled without a shared Redis backend; lease and reservation state will be kept " \
1046
+ "per-process. Configure datastream_options[:redis_client] (or credit_leases[:redis_client]) so " \
1047
+ "leases gate correctly across multiple SDK instances."
1048
+ )
1049
+ @lease_store = Credits::Leases::LeaseStore.new
1050
+ @reservations = Credits::Leases::ReservationStore.new(@lease_store, sweep_ms, logger: @logger)
1051
+ end
1052
+
1053
+ @reservations.start_sweep
1054
+ @credit_lease_manager = Credits::Leases::LeaseManager.new(
1055
+ wire_client: Credits::Leases::ApiWireClient.new(credits_client: credits),
1056
+ lease_store: @lease_store,
1057
+ logger: @logger,
1058
+ config: config
1059
+ )
1060
+ @prewarm_resolve_timeout_ms =
1061
+ config[:prewarm_resolve_timeout_ms] || Credits::Leases::DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS
1062
+ end
1063
+
1064
+ # Whether the configured mode wants the local lease plumbing. Read during
1065
+ # construction, after the DataStream client has been wired, so :auto can
1066
+ # resolve against it.
1067
+ def credit_lease_mode_uses_leases?
1068
+ return false if @credit_lease_mode.nil? || @credit_lease_mode == :server
1069
+ return true if @credit_lease_mode == :client
1070
+
1071
+ !@datastream_client.nil?
1072
+ end
1073
+
1074
+ # Which reservation mode a check with usage resolves to right now. Nil means
1075
+ # no credit gating at all: credit_leases is not configured, or the client is
1076
+ # offline.
1077
+ #
1078
+ # :auto is resolved per check rather than once at startup, so a DataStream
1079
+ # that failed to start after construction falls to server mode instead of
1080
+ # silently dropping every check to a plain, ungated flag check.
1081
+ def effective_lease_mode
1082
+ return nil if @credit_lease_mode.nil? || @offline
1083
+ return :server if @credit_lease_mode == :server
1084
+ return :client if @credit_lease_mode == :client
1085
+
1086
+ plumbing_ready = !@credit_lease_manager.nil? && !@lease_store.nil? && !@reservations.nil?
1087
+ @datastream_client && plumbing_ready ? :client : :server
1088
+ end
1089
+
1090
+ # The plain (non-lease) check the credit paths fall back to, with the
1091
+ # caller's preflight threaded through so a client-side evaluation still
1092
+ # gates on the post-call balance.
1093
+ def plain_check_result(flag_key, company, user, options)
1094
+ # The caller's default_value governs this path too. Without it a check
1095
+ # that falls back and then fails would answer with the registered flag
1096
+ # default, denying where the caller asked to allow.
1097
+ response = check_flag_with_entitlement(
1098
+ flag_key, company: company, user: user,
1099
+ preflight: Credits::Leases.build_preflight_options(options),
1100
+ default_value: options[:default_value], timeout_ms: options[:timeout_ms]
1101
+ )
1102
+ value = response.value
1103
+ Credits::Leases::CheckResult.new(
1104
+ allowed: value, value: value, reason: response.reason, entitlement: response.entitlement,
1105
+ flag_key: response.flag_key || flag_key, flag_id: response.flag_id, error: response.error
1106
+ )
1107
+ end
1108
+
1109
+ def resolve_default_value(flag_key, default_value)
1110
+ return get_flag_default(flag_key) if default_value.nil?
1111
+ return default_value.call if default_value.respond_to?(:call)
1112
+
1113
+ default_value
1114
+ end
1115
+
1116
+ def enqueue_lease_flag_check_event(body)
1117
+ payload = {
1118
+ flag_key: body[:flag_key],
1119
+ value: body[:value],
1120
+ reason: body[:reason]
1121
+ }
1122
+ payload[:error] = body[:error] if body[:error]
1123
+ payload[:flag_id] = body[:flag_id] if body[:flag_id]
1124
+ payload[:rule_id] = body[:rule_id] if body[:rule_id]
1125
+ payload[:company_id] = body[:company_id] if body[:company_id]
1126
+ payload[:user_id] = body[:user_id] if body[:user_id]
1127
+ payload[:company] = body[:req_company] if body[:req_company]&.any?
1128
+ payload[:user] = body[:req_user] if body[:req_user]&.any?
1129
+
1130
+ @event_buffer.push({ event_type: "flag_check", body: payload, sent_at: Time.now.utc.iso8601 })
1131
+ rescue StandardError => e
1132
+ @logger.error("Error enqueueing flag_check event: #{e.message}")
1133
+ end
1134
+
1135
+ def settle_reservation(reservation, actual_quantity, traits)
1136
+ idempotency_key = "#{RESERVATION_TRACK_IDEMPOTENCY_PREFIX}#{reservation.id}"
1137
+ # Server mode: the hold lives on the server and settles by id, so there is
1138
+ # nothing local to consume or refund. Just emit the track.
1139
+ if reservation.server_mode? || @reservations.nil?
1140
+ @logger.warn("track_with_reservation called but credit_leases is not configured; emitting unsettled track") if @reservations.nil? && !reservation.server_mode?
1141
+ # Without a local store there is nothing to settle against, but the
1142
+ # billing event must still carry the lease id (the handle was issued by
1143
+ # a lease-configured client, and dropping it would double-debit the
1144
+ # grant) and the deterministic idempotency key.
1145
+ track(Credits::Leases.build_reservation_track_event(reservation, actual_quantity, traits: traits),
1146
+ options: { idempotency_key: idempotency_key })
1147
+ return
1148
+ end
1149
+
1150
+ outcome = begin
1151
+ Credits::Leases.consume_reservation_and_build_event(@reservations, reservation, actual_quantity,
1152
+ traits: traits)
1153
+ rescue StandardError => e
1154
+ # The local settle failed, most likely an unreachable Redis. The usage
1155
+ # still has to be billed: build the track from the caller-held handle
1156
+ # and emit it anyway. The unsettled local hold is reclaimed by the
1157
+ # sweeper at its TTL or at lease expiry, and the idempotency key keeps a
1158
+ # retried settle from double billing.
1159
+ @logger.warn(
1160
+ "track_with_reservation: failed to settle reservation #{reservation.id} locally (#{e.message}), " \
1161
+ "emitting track anyway"
1162
+ )
1163
+ Credits::Leases::SettleOutcome.new(
1164
+ track: Credits::Leases.build_reservation_track_event(reservation, actual_quantity, traits: traits),
1165
+ settled_locally: false
1166
+ )
1167
+ end
1168
+
1169
+ unless outcome.settled_locally
1170
+ @logger.debug(
1171
+ "track_with_reservation: reservation #{reservation.id} was not settled locally (expired or swept, " \
1172
+ "already settled, or store unreachable), emitting track keyed for idempotent server-side dedupe"
1173
+ )
1174
+ end
1175
+ # The cached company metric moves only when this call moved local state
1176
+ # with it: the server drops a duplicate event on the key, so bumping the
1177
+ # metric for one would have a caller's retry deny its own next
1178
+ # numeric-limit check until the stream pushes the real figure.
1179
+ emit_track(outcome.track, { idempotency_key: idempotency_key }, update_metrics: outcome.settled_locally)
1180
+ end
1181
+
1182
+ # Enqueue a track event, optimistically bumping the cached company metric
1183
+ # with it unless the caller says not to. The bump is a local prediction of
1184
+ # what the stream will push back, so it belongs only to an event that
1185
+ # records usage the server has not already counted.
1186
+ def emit_track(body, options, update_metrics:)
1187
+ return if @offline
1188
+
1189
+ @event_buffer.push(build_event("track", body, options, TRACK_OPTION_KEYS))
1190
+
1191
+ if update_metrics && @datastream_client&.connected? && body[:company]
1192
+ event_name = body[:event] || body["event"]
1193
+ quantity = body[:quantity] || body["quantity"] || 1
1194
+ @datastream_client.update_company_metrics(body[:company], event_name, quantity)
1195
+ end
1196
+ rescue StandardError => e
1197
+ @logger.error("Error sending track event: #{e.message}")
1198
+ end
1199
+
1200
+ def prewarm_after_identify(body, credit_type_ids)
1201
+ # A thread that would only log "no-op in server mode" is still a thread,
1202
+ # and close still has to wait it out. Decide before spawning one.
1203
+ return nil if @credit_lease_manager.nil? || @lease_store.nil?
1204
+
1205
+ company = identify_company_keys(body)
1206
+
1207
+ thread = Thread.new do
1208
+ # Force a flush so the server processes the identify as soon as
1209
+ # possible. Without it the company may sit in the local buffer for up to
1210
+ # the flush interval before the server even sees it, and prewarm's
1211
+ # bounded poll would just be waiting on us. It runs here rather than on
1212
+ # the caller's thread because a flush is an HTTP post with retries, and
1213
+ # identify with a prewarm must stay the buffer push that identify
1214
+ # without one is. The ordering the poll needs still holds: the flush and
1215
+ # the poll are the same thread, in that order. close waits on this
1216
+ # thread, so a shutdown still covers the flush.
1217
+ begin
1218
+ @event_buffer.flush
1219
+ rescue StandardError => e
1220
+ @logger.debug("identify flush before prewarm failed: #{e.message}")
1221
+ end
1222
+ prewarm(credit_type_ids, company: company)
1223
+ rescue StandardError => e
1224
+ @logger.warn("identify prewarm failed: #{e.message}")
1225
+ end
1226
+ thread.abort_on_exception = false
1227
+ @pending_prewarms_mutex.synchronize do
1228
+ @pending_prewarms.select!(&:alive?)
1229
+ @pending_prewarms << thread
1230
+ end
1231
+ nil
1232
+ end
1233
+
1234
+ # The Schematic company id for a set of entity keys, resolved in the
1235
+ # server's order: every supplied key/value pair is an ordinary entity key
1236
+ # and gets looked up first; only when nothing matches is a value read as the
1237
+ # company's own id, by its comp_ prefix rather than by the name of the key
1238
+ # it sits under. An account is free to define a key called "id" holding its
1239
+ # own identifier, so the name alone settles nothing.
1240
+ #
1241
+ # On a cache miss this actively fetches the company over the datastream,
1242
+ # warming the cache as a side effect. identify does not push a company into
1243
+ # that cache: companies are only streamed in response to a request. So this
1244
+ # fetches rather than passively polling the cache, which would watch an
1245
+ # empty cache until it times out. Fetching also primes the cache so the
1246
+ # first real check hits the lease path instead of falling back.
1247
+ def resolve_company_id_with_wait(company)
1248
+ return schematic_company_id(company) if @datastream_client.nil?
1249
+
1250
+ cached = @datastream_client.get_cached_company(company)
1251
+ cached_id = cached && (cached[:id] || cached["id"])
1252
+ return cached_id if cached_id
1253
+ # A zero or negative timeout means cache-only: answer from what the
1254
+ # DataStream already holds and never fetch or poll. A prewarm still
1255
+ # acquires when an earlier check warmed the company.
1256
+ return schematic_company_id(company) if @prewarm_resolve_timeout_ms <= 0
1257
+
1258
+ deadline = monotonic_ms + @prewarm_resolve_timeout_ms
1259
+ loop do
1260
+ # A company resolved for a client that is shutting down warms nothing,
1261
+ # and close would be waiting out the rest of this poll.
1262
+ return nil if @closing
1263
+
1264
+ if @datastream_client.connected?
1265
+ resolved_id = fetch_company_id_within(company, deadline)
1266
+ return resolved_id if resolved_id
1267
+ else
1268
+ # A request sent over a closed socket is dropped without an error, so
1269
+ # the fetch would just sit out its own timeout. Poll the cache until
1270
+ # the socket returns or the budget runs out.
1271
+ cached = @datastream_client.get_cached_company(company)
1272
+ cached_id = cached && (cached[:id] || cached["id"])
1273
+ return cached_id if cached_id
1274
+ end
1275
+ # The keys never resolved, so fall back to a comp_ value the way the
1276
+ # server does once its own key lookup comes up empty.
1277
+ return schematic_company_id(company) if monotonic_ms >= deadline
1278
+
1279
+ sleep([Credits::Leases::DEFAULT_PREWARM_POLL_INTERVAL_MS, deadline - monotonic_ms].min / 1000.0)
1280
+ end
1281
+ end
1282
+
1283
+ # The Schematic id hiding among a set of entity keys, recognized by its
1284
+ # secure-id prefix. The server reads keys this way once a key lookup has
1285
+ # come up empty, so { account_id: "comp_1" } resolves and { id: "acme" }
1286
+ # does not: the prefix decides, not the key's name.
1287
+ def schematic_company_id(keys)
1288
+ return nil unless keys.is_a?(Hash)
1289
+
1290
+ keys.values.find { |v| v.is_a?(String) && v.start_with?(COMPANY_ID_PREFIX) }
1291
+ end
1292
+
1293
+ # A caller can hand identify any shape, so read the company keys without
1294
+ # assuming one.
1295
+ def identify_company_keys(body)
1296
+ return nil unless body.is_a?(Hash)
1297
+
1298
+ company = body[:company] || body["company"]
1299
+ return nil unless company.is_a?(Hash)
1300
+
1301
+ company[:keys] || company["keys"]
1302
+ end
1303
+
1304
+ # get_company waits on the DataStream's own resource timeout, which is many
1305
+ # times the prewarm budget, so it runs on a thread joined to what is left.
1306
+ # An abandoned fetch is left to finish: it still warms the cache for the
1307
+ # next poll or the first real check.
1308
+ def fetch_company_id_within(company, deadline)
1309
+ remaining = deadline - monotonic_ms
1310
+ return nil if remaining <= 0
1311
+
1312
+ fetch = Thread.new { @datastream_client.get_company(company) }
1313
+ fetch.abort_on_exception = false
1314
+ resolved = fetch.join(remaining / 1000.0)&.value
1315
+ resolved && (resolved[:id] || resolved["id"])
1316
+ rescue StandardError => e
1317
+ @logger.debug("prewarm: datastream company fetch failed (#{e.message})")
1318
+ nil
1319
+ end
1320
+
1321
+ def shut_down_credit_leases
1322
+ @reservations&.stop
1323
+ return if @credit_lease_manager.nil?
1324
+
1325
+ # Refuse new lease work first, so the waits below are waiting on work that
1326
+ # is already unwinding rather than work still starting. Both steps run for
1327
+ # a shared backend too: the work must not outlive the client, even where
1328
+ # there is nothing to release.
1329
+ @credit_lease_manager.stop
1330
+ # One budget across both waits, not each timeout in turn: a caller closing
1331
+ # a client wants a bounded shutdown, not the sum of every wait inside it.
1332
+ deadline = monotonic_ms + Credits::Leases::SHUTDOWN_DRAIN_TIMEOUT_MS
1333
+ prewarms = @pending_prewarms_mutex.synchronize { @pending_prewarms.dup }
1334
+ prewarms.each do |thread|
1335
+ remaining = deadline - monotonic_ms
1336
+ break if remaining <= 0
1337
+
1338
+ thread.join(remaining / 1000.0)
1339
+ end
1340
+ if prewarms.any?(&:alive?)
1341
+ @logger.warn(
1342
+ "Timed out after #{Credits::Leases::SHUTDOWN_DRAIN_TIMEOUT_MS}ms waiting for in-flight prewarms on close"
1343
+ )
1344
+ end
1345
+ @credit_lease_manager.drain([deadline - monotonic_ms, 0].max)
1346
+ return if @lease_backend_shared
1347
+
1348
+ # The releases share the shutdown budget too, so a slow API cannot stretch
1349
+ # close past what the caller was promised.
1350
+ @credit_lease_manager.release_all_local_leases([deadline - monotonic_ms, 0].max)
1351
+ end
1352
+
1353
+ def monotonic_ms
1354
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
1355
+ end
1356
+
560
1357
  def setup_datastream(options)
561
1358
  @rules_engine = RulesEngine.new(logger: @logger)
562
1359