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.
- checksums.yaml +4 -4
- data/.fern/metadata.json +3 -3
- data/.fern/replay.lock +8 -338
- data/.fernignore +6 -0
- data/README.md +141 -0
- data/custom.gemspec.rb +6 -0
- data/lib/schematic/accounts/types/update_environment_request_body.rb +2 -0
- data/lib/schematic/client.rb +1 -1
- data/lib/schematic/credits/client.rb +50 -6
- data/lib/schematic/credits/leases/check.rb +477 -0
- data/lib/schematic/credits/leases/lease_manager.rb +565 -0
- data/lib/schematic/credits/leases/lease_store.rb +240 -0
- data/lib/schematic/credits/leases/redis_lease_store.rb +358 -0
- data/lib/schematic/credits/leases/redis_reservation_store.rb +326 -0
- data/lib/schematic/credits/leases/reservation_store.rb +161 -0
- data/lib/schematic/credits/leases/server_check.rb +237 -0
- data/lib/schematic/credits/leases/track.rb +66 -0
- data/lib/schematic/credits/leases/types.rb +329 -0
- data/lib/schematic/credits/leases/wire_client.rb +83 -0
- data/lib/schematic/credits/types/acquire_credit_lease_request_body.rb +2 -0
- data/lib/schematic/credits/types/create_credit_spend_policy_request_body.rb +5 -1
- data/lib/schematic/credits/types/extend_credit_lease_request_body.rb +2 -0
- data/lib/schematic/credits/types/get_credit_spend_policy_usage_params.rb +16 -0
- data/lib/schematic/credits/types/get_credit_spend_policy_usage_request.rb +15 -0
- data/lib/schematic/credits/types/get_credit_spend_policy_usage_response.rb +13 -0
- data/lib/schematic/credits/types/update_credit_spend_policy_request_body.rb +4 -0
- data/lib/schematic/datastream/client.rb +32 -2
- data/lib/schematic/entitlements/client.rb +165 -0
- data/lib/schematic/entitlements/types/count_company_user_usage_params.rb +24 -0
- data/lib/schematic/entitlements/types/count_company_user_usage_request.rb +23 -0
- data/lib/schematic/entitlements/types/count_company_user_usage_response.rb +13 -0
- data/lib/schematic/entitlements/types/get_company_user_usage_metrics_params.rb +16 -0
- data/lib/schematic/entitlements/types/get_company_user_usage_metrics_request.rb +15 -0
- data/lib/schematic/entitlements/types/get_company_user_usage_metrics_response.rb +13 -0
- data/lib/schematic/entitlements/types/list_company_user_usage_params.rb +24 -0
- data/lib/schematic/entitlements/types/list_company_user_usage_request.rb +23 -0
- data/lib/schematic/entitlements/types/list_company_user_usage_response.rb +13 -0
- data/lib/schematic/plangroups/client.rb +2 -0
- data/lib/schematic/plangroups/types/create_plan_group_request_body.rb +2 -0
- data/lib/schematic/plangroups/types/update_plan_group_request_body.rb +2 -0
- data/lib/schematic/planmigrations/client.rb +6 -0
- data/lib/schematic/planmigrations/types/count_migrations_params.rb +2 -0
- data/lib/schematic/planmigrations/types/count_migrations_request.rb +2 -0
- data/lib/schematic/planmigrations/types/create_migration_input.rb +2 -0
- data/lib/schematic/planmigrations/types/list_migrations_params.rb +2 -0
- data/lib/schematic/planmigrations/types/list_migrations_request.rb +2 -0
- data/lib/schematic/plans/types/publish_plan_version_request_body.rb +4 -0
- data/lib/schematic/plans/types/retry_custom_plan_billing_request_body.rb +2 -0
- data/lib/schematic/rules_engine.rb +37 -0
- data/lib/schematic/schematic_client.rb +830 -33
- data/lib/schematic/types/capture_raw_event.rb +4 -0
- data/lib/schematic/types/check_flags_response_data.rb +2 -0
- data/lib/schematic/types/company_detail_response_data.rb +2 -0
- data/lib/schematic/types/company_plan_detail_response_data.rb +2 -0
- data/lib/schematic/types/company_user_usage_metrics_response_data.rb +15 -0
- data/lib/schematic/types/company_user_usage_response_data.rb +19 -0
- data/lib/schematic/types/company_user_usage_row_response_data.rb +17 -0
- data/lib/schematic/types/component_display_settings.rb +2 -0
- data/lib/schematic/types/component_settings_response_data.rb +2 -0
- data/lib/schematic/types/credit_event_ledger_response_data.rb +5 -1
- data/lib/schematic/types/credit_event_type.rb +3 -0
- data/lib/schematic/types/credit_ledger_entry_kind.rb +17 -0
- data/lib/schematic/types/credit_spend_policy.rb +25 -0
- data/lib/schematic/types/credit_spend_policy_response_data.rb +12 -0
- data/lib/schematic/types/credit_spend_window.rb +11 -0
- data/lib/schematic/types/credit_spend_window_unit.rb +13 -0
- data/lib/schematic/types/custom_plan_billing_response_data.rb +2 -0
- data/lib/schematic/types/environment_detail_response_data.rb +2 -0
- data/lib/schematic/types/environment_response_data.rb +2 -0
- data/lib/schematic/types/estimated_plan_total.rb +13 -0
- data/lib/schematic/types/pending_migration_response_data.rb +6 -0
- data/lib/schematic/types/plan_version_migration_response_data.rb +2 -0
- data/lib/schematic/types/plan_version_migration_strategy.rb +1 -0
- data/lib/schematic/types/rules_engine_schema_version.rb +1 -1
- data/lib/schematic/types/rulesengine_company.rb +2 -0
- data/lib/schematic/types/rulesengine_credit_spend_policy.rb +25 -0
- data/lib/schematic/types/rulesengine_credit_spend_policy_scope.rb +13 -0
- data/lib/schematic/types/rulesengine_credit_spend_window.rb +11 -0
- data/lib/schematic/types/rulesengine_user.rb +2 -0
- data/lib/schematic/types/user_usage_metric.rb +12 -0
- data/lib/schematic/version.rb +1 -1
- data/lib/schematic.rb +26 -2
- data/lib/schematichq.rb +10 -0
- data/reference.md +474 -9
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
# ---
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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(
|
|
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
|
-
|
|
438
|
-
|
|
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:
|
|
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
|
|