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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Schematic
|
|
7
|
+
module Credits
|
|
8
|
+
module Leases
|
|
9
|
+
# Everything a server-mode check needs. reservation_ttl_ms is how far out
|
|
10
|
+
# the hold's expires_at is set, and default_value resolves the caller's
|
|
11
|
+
# default for the flag, which the fail-open branch returns because there
|
|
12
|
+
# is no local engine to re-run.
|
|
13
|
+
ServerCheckDeps = Struct.new(:features, :credits, :logger, :reservation_ttl_ms, :default_value, :clock,
|
|
14
|
+
keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# Mirrors the reason the API returns on a 200 with value false for the
|
|
17
|
+
# same denial, so a caller matching on reason has one string to match
|
|
18
|
+
# either way.
|
|
19
|
+
INSUFFICIENT_CREDITS_REASON = "Insufficient credits"
|
|
20
|
+
|
|
21
|
+
# Drive a single check with usage set, in server mode.
|
|
22
|
+
#
|
|
23
|
+
# One check-and-reserve call does everything the client path spreads
|
|
24
|
+
# across a lease acquire, a local reserve, and a rules evaluation: the
|
|
25
|
+
# server evaluates the flag against the company's real balance, applies
|
|
26
|
+
# the preflight cost, and takes the hold in the same round trip. There is
|
|
27
|
+
# no lease, no local store, and no rules engine involved.
|
|
28
|
+
#
|
|
29
|
+
# The failure contract differs from client mode in one place. fail-open
|
|
30
|
+
# there means re-run the engine with the credit balance assumed
|
|
31
|
+
# sufficient, so plan targeting and every non-credit condition still
|
|
32
|
+
# apply. Server mode has no local engine to re-run, since the call that
|
|
33
|
+
# would have answered is the one that failed, so fail-open returns the
|
|
34
|
+
# caller's default value instead. fail-closed denies, same as client mode.
|
|
35
|
+
#
|
|
36
|
+
# No flag_check event is enqueued here: the server logs the flag check for
|
|
37
|
+
# check-and-reserve itself, the same way the REST check path does.
|
|
38
|
+
def self.check_with_server_reservation(deps, key, eval_ctx, options, &fallback)
|
|
39
|
+
ServerCheck.new(deps, key, eval_ctx, options, fallback).run
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class ServerCheck
|
|
43
|
+
def initialize(deps, key, eval_ctx, options, fallback)
|
|
44
|
+
@deps = deps
|
|
45
|
+
@key = key
|
|
46
|
+
@eval_ctx = eval_ctx || {}
|
|
47
|
+
@options = options || {}
|
|
48
|
+
@fallback = fallback
|
|
49
|
+
@logger = deps.logger
|
|
50
|
+
@clock = deps.clock || DEFAULT_CLOCK
|
|
51
|
+
@on_failure = Leases.resolve_failure_mode(@options[:on_acquire_failure], @logger)
|
|
52
|
+
# One key for this check, minted before the call rather than per
|
|
53
|
+
# attempt: check-and-reserve takes a hold, so a 502 arriving after the
|
|
54
|
+
# API committed one would otherwise have the retry take a second and
|
|
55
|
+
# park the first until its TTL. Sharing the key across attempts makes
|
|
56
|
+
# the server return the hold it already took.
|
|
57
|
+
@idempotency_key = SecureRandom.uuid
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run
|
|
61
|
+
usage = @options[:usage]
|
|
62
|
+
# The same guard as the client path: a malformed usage must never
|
|
63
|
+
# reach the wire. NaN slips through every numeric comparison, so the
|
|
64
|
+
# server would size a hold off a value no comparison can reject.
|
|
65
|
+
unless Leases.valid_quantity?(usage)
|
|
66
|
+
@logger.error(
|
|
67
|
+
"Server reservation: invalid usage #{usage.inspect} for flag #{@key}, " \
|
|
68
|
+
"must be a finite non-negative number"
|
|
69
|
+
)
|
|
70
|
+
return failure_result("invalid_usage")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if usage.zero?
|
|
74
|
+
@logger.debug("Server reservation: usage is 0 for flag #{@key}, nothing to reserve, using plain check")
|
|
75
|
+
return @fallback.call
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
data = call_api
|
|
79
|
+
return data if data.is_a?(CheckResult)
|
|
80
|
+
|
|
81
|
+
build_result(data)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def call_api
|
|
87
|
+
response = @deps.features.check_and_reserve_flag(request_options: request_options, **request_body)
|
|
88
|
+
response.data
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
# A 402 is the server's definitive answer, not a can't-gate: it knows
|
|
91
|
+
# the credits are not there. Deny regardless of the failure mode,
|
|
92
|
+
# since failing open here would hand out credit the balance cannot
|
|
93
|
+
# cover. check-and-reserve itself answers 200 with value false for
|
|
94
|
+
# insufficient credits; this is defensive.
|
|
95
|
+
return payment_required_result(e) if payment_required?(e)
|
|
96
|
+
|
|
97
|
+
@logger.error("Server reservation: check-and-reserve for flag #{@key} failed: #{e.message}")
|
|
98
|
+
failure_result("server_reservation_failed")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request_body
|
|
102
|
+
# The request body's quantity is an integer, so a fractional usage
|
|
103
|
+
# would truncate and the server would size the hold below the work
|
|
104
|
+
# about to run. Round up, and size the preflight from the same number
|
|
105
|
+
# so the flag is evaluated against the quantity actually held.
|
|
106
|
+
quantity = Leases.wire_quantity(@options[:usage])
|
|
107
|
+
body = {
|
|
108
|
+
key: @key,
|
|
109
|
+
quantity: quantity,
|
|
110
|
+
expires_at: (@clock.call + (@deps.reservation_ttl_ms / 1000.0)).utc.iso8601
|
|
111
|
+
}
|
|
112
|
+
company = @eval_ctx[:company] || @eval_ctx["company"]
|
|
113
|
+
user = @eval_ctx[:user] || @eval_ctx["user"]
|
|
114
|
+
body[:company] = company if company && !company.empty?
|
|
115
|
+
body[:user] = user if user && !user.empty?
|
|
116
|
+
preflight = Leases.build_preflight_options(@options.merge(usage: quantity))
|
|
117
|
+
body[:preflight] = preflight if preflight
|
|
118
|
+
body[:idempotency_key] = @idempotency_key
|
|
119
|
+
body
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Only the timeout is set here. The call keeps the client's default
|
|
123
|
+
# retry policy, which the idempotency key makes safe.
|
|
124
|
+
def request_options
|
|
125
|
+
return {} if @options[:timeout_ms].nil?
|
|
126
|
+
|
|
127
|
+
{ timeout_in_seconds: @options[:timeout_ms] / 1000.0 }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_result(data)
|
|
131
|
+
base = CheckResult.new(
|
|
132
|
+
allowed: data.value, value: data.value, reason: data.reason, entitlement: data.entitlement,
|
|
133
|
+
flag_key: data.flag || @key, flag_id: data.flag_id, error: data.error
|
|
134
|
+
)
|
|
135
|
+
held = data.reservation
|
|
136
|
+
# No reservation comes back when the flag denied, the credits were
|
|
137
|
+
# insufficient (a 200 with value false), or the feature is not
|
|
138
|
+
# credit-metered. Nothing was held, so there is nothing to release.
|
|
139
|
+
return base if !data.value || held.nil?
|
|
140
|
+
|
|
141
|
+
# The settling track event is named by the event subtype; the caller's
|
|
142
|
+
# explicit one wins, otherwise the server names it on the hold. With
|
|
143
|
+
# neither, the hold could never be settled.
|
|
144
|
+
event_subtype = @options[:event_subtype] || held.event_subtype
|
|
145
|
+
return release_unsettleable(held, base) if event_subtype.nil? || event_subtype.empty?
|
|
146
|
+
|
|
147
|
+
CheckResult.new(
|
|
148
|
+
allowed: true, value: true, reason: data.reason, entitlement: data.entitlement,
|
|
149
|
+
flag_key: data.flag || @key, flag_id: data.flag_id,
|
|
150
|
+
reservation: reservation_from(held, event_subtype)
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def reservation_from(held, event_subtype)
|
|
155
|
+
Reservation.new(
|
|
156
|
+
id: held.id,
|
|
157
|
+
# No lease exists in server mode; mirror the id so the field stays
|
|
158
|
+
# populated and a handle round-trips through code that reads it.
|
|
159
|
+
lease_id: held.id,
|
|
160
|
+
mode: :server,
|
|
161
|
+
company_id: held.company_id,
|
|
162
|
+
credit_type_id: held.credit_type_id,
|
|
163
|
+
event_subtype: event_subtype,
|
|
164
|
+
quantity_reserved: held.quantity_reserved,
|
|
165
|
+
credits_reserved: held.credits_reserved,
|
|
166
|
+
consumption_rate: held.consumption_rate,
|
|
167
|
+
expires_at: parse_time(held.expires_at),
|
|
168
|
+
eval_ctx: @eval_ctx
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def release_unsettleable(held, base)
|
|
173
|
+
@logger.error(
|
|
174
|
+
"Server reservation: reservation #{held.id} for flag #{@key} has no event subtype, " \
|
|
175
|
+
"releasing, it could never be settled"
|
|
176
|
+
)
|
|
177
|
+
begin
|
|
178
|
+
@deps.credits.release_credit_reservation(reservation_id: held.id)
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
@logger.warn(
|
|
181
|
+
"Server reservation: failed to release #{held.id} (#{e.message}); its hold is refunded when it expires"
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
return failure_result("missing_event_subtype") if @on_failure == :fail_closed
|
|
185
|
+
|
|
186
|
+
# Fail-open means assume the credits are there, and the server has
|
|
187
|
+
# already evaluated the flag and allowed this check. Only the settle
|
|
188
|
+
# is impossible, so keep the server's verdict rather than falling back
|
|
189
|
+
# to the caller's default, which could deny what the server allowed.
|
|
190
|
+
CheckResult.new(
|
|
191
|
+
allowed: base.allowed, value: base.value, reason: base.reason, entitlement: base.entitlement,
|
|
192
|
+
flag_key: base.flag_key, flag_id: base.flag_id, error: "missing_event_subtype"
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Resolve a can't-gate outcome. fail-closed denies; fail-open returns
|
|
197
|
+
# the caller's default value, since there is no local engine to
|
|
198
|
+
# re-evaluate with an assumed-sufficient balance the way client mode
|
|
199
|
+
# does.
|
|
200
|
+
def failure_result(reason)
|
|
201
|
+
return CheckResult.new(allowed: false, value: false, reason: reason, flag_key: @key, error: reason) if @on_failure == :fail_closed
|
|
202
|
+
|
|
203
|
+
value = @deps.default_value.call
|
|
204
|
+
CheckResult.new(allowed: value, value: value, reason: "#{reason}_fail_open", flag_key: @key, error: reason)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The generated client has no 402-specific error class, so a payment
|
|
208
|
+
# required arrives as a ClientError carrying the status code.
|
|
209
|
+
def payment_required?(error)
|
|
210
|
+
error.respond_to?(:code) && error.code.to_i == 402
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def payment_required_result(error)
|
|
214
|
+
CheckResult.new(
|
|
215
|
+
allowed: false, value: false, reason: INSUFFICIENT_CREDITS_REASON, flag_key: @key,
|
|
216
|
+
error: api_error_message(error)
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A response error's message is the raw body, which carries the API's
|
|
221
|
+
# own error string when it is JSON.
|
|
222
|
+
def api_error_message(error)
|
|
223
|
+
parsed = JSON.parse(error.message.to_s)
|
|
224
|
+
parsed.is_a?(Hash) && parsed["error"] ? parsed["error"] : error.message
|
|
225
|
+
rescue StandardError
|
|
226
|
+
error.message
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def parse_time(value)
|
|
230
|
+
Leases.parse_api_time(value)
|
|
231
|
+
rescue StandardError
|
|
232
|
+
@clock.call
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schematic
|
|
4
|
+
module Credits
|
|
5
|
+
module Leases
|
|
6
|
+
# What a settle did locally, and what it owes the server.
|
|
7
|
+
#
|
|
8
|
+
# settled_locally is true when the hold was still open and this call
|
|
9
|
+
# debited the consumed slice and refunded the rest. False when it had
|
|
10
|
+
# already been swept at its TTL, already settled, or the store was
|
|
11
|
+
# unreachable: the lease balance was not touched here, so it reads high
|
|
12
|
+
# until the lease rolls over, and the event is a recovery emit.
|
|
13
|
+
SettleOutcome = Struct.new(:track, :settled_locally, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Consume a reservation against its lease and build the event that bills
|
|
16
|
+
# it.
|
|
17
|
+
#
|
|
18
|
+
# The event is built from the caller-held handle rather than the store, so
|
|
19
|
+
# the usage is still billed once the hold has been swept. Only the local
|
|
20
|
+
# bookkeeping clamps to the reserved amount; the event carries the
|
|
21
|
+
# unclamped actual, because the server is the source of truth for real
|
|
22
|
+
# consumption.
|
|
23
|
+
def self.consume_reservation_and_build_event(reservations, reservation, actual_quantity, traits: nil)
|
|
24
|
+
# Rounded up for the same reason the hold is (see check_with_lease), and
|
|
25
|
+
# through the same helper the event's quantity uses: the debit has to
|
|
26
|
+
# move the local ledger by exactly what the Track event bills.
|
|
27
|
+
consumed = reservations.consume(
|
|
28
|
+
reservation.id, Leases.wire_quantity(actual_quantity) * reservation.consumption_rate
|
|
29
|
+
)
|
|
30
|
+
SettleOutcome.new(
|
|
31
|
+
track: build_reservation_track_event(reservation, actual_quantity, traits: traits),
|
|
32
|
+
settled_locally: !consumed.nil?
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build the track event for a reservation from the handle alone, with no
|
|
37
|
+
# store access, so the client can still bill the usage when the local
|
|
38
|
+
# settle fails against an unreachable store.
|
|
39
|
+
def self.build_reservation_track_event(reservation, actual_quantity, traits: nil)
|
|
40
|
+
# Whole event units, the same rounding the hold and the settle debit
|
|
41
|
+
# use: the API rejects a non-integer quantity during processing, and
|
|
42
|
+
# the local ledger has to move by what this event bills.
|
|
43
|
+
body = { event: reservation.event_subtype, quantity: Leases.wire_quantity(actual_quantity) }
|
|
44
|
+
if reservation.server_mode?
|
|
45
|
+
# The hold lives on the server, so the event settles it by id. Never
|
|
46
|
+
# send lease_id too: the server prefers it when both are set, and
|
|
47
|
+
# there is no lease here for it to route through.
|
|
48
|
+
body[:reservation_id] = reservation.id
|
|
49
|
+
else
|
|
50
|
+
# Routes the server-side credit consumption through the lease's
|
|
51
|
+
# sub-ledger instead of decrementing the grant again, which the
|
|
52
|
+
# acquire already pre-debited. Without this the grant double-debits
|
|
53
|
+
# and eventually starves redemptions mid-session.
|
|
54
|
+
body[:lease_id] = reservation.lease_id
|
|
55
|
+
end
|
|
56
|
+
eval_ctx = reservation.eval_ctx || {}
|
|
57
|
+
company = eval_ctx[:company] || eval_ctx["company"]
|
|
58
|
+
user = eval_ctx[:user] || eval_ctx["user"]
|
|
59
|
+
body[:company] = company if company && !company.empty?
|
|
60
|
+
body[:user] = user if user && !user.empty?
|
|
61
|
+
body[:traits] = traits if traits
|
|
62
|
+
body
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Schematic
|
|
6
|
+
module Credits
|
|
7
|
+
# Client-side credit leases, reservations, and preflight checks.
|
|
8
|
+
#
|
|
9
|
+
# A credit-metered feature is gated without a wire call per check: the SDK
|
|
10
|
+
# leases a tranche of credits per (company, credit type), carves a
|
|
11
|
+
# reservation out of it at check time, and settles the reservation against
|
|
12
|
+
# the actual usage when the work finishes. See conformance/SPEC.md at the
|
|
13
|
+
# repo root for the full model; the vectors there pin the semantics every
|
|
14
|
+
# Schematic SDK shares.
|
|
15
|
+
module Leases
|
|
16
|
+
# Durations are milliseconds throughout, matching the names and units the
|
|
17
|
+
# other SDKs use so one set of numbers configures a mixed fleet.
|
|
18
|
+
|
|
19
|
+
DEFAULT_LEASE_DURATION_MS = 5 * 60 * 1000
|
|
20
|
+
DEFAULT_RESERVATION_TTL_MS = 60 * 1000
|
|
21
|
+
# The API rejects a hold whose expires_at is more than an hour out, so a
|
|
22
|
+
# larger reservation TTL would fail every server-mode check. The SDK
|
|
23
|
+
# clamps to this instead.
|
|
24
|
+
MAX_RESERVATION_TTL_MS = 60 * 60 * 1000
|
|
25
|
+
# The API measures that hour against its own clock while the SDK computes
|
|
26
|
+
# expires_at against the caller's, so a client running ahead would be
|
|
27
|
+
# rejected at exactly the cap. Hold this much back from it.
|
|
28
|
+
RESERVATION_TTL_SKEW_ALLOWANCE_MS = 60 * 1000
|
|
29
|
+
DEFAULT_LEASE_SIZE = 10_000
|
|
30
|
+
DEFAULT_LOW_WATER_MARK = 0.25
|
|
31
|
+
DEFAULT_SWEEP_INTERVAL_MS = 1000
|
|
32
|
+
# How long prewarm is willing to wait for a freshly identified company to
|
|
33
|
+
# surface in the datastream cache before giving up. Long enough to cover
|
|
34
|
+
# the buffer-flush, server-ingest, datastream-push round trip for a new
|
|
35
|
+
# company; short enough that a misconfigured caller does not hang.
|
|
36
|
+
DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS = 5000
|
|
37
|
+
DEFAULT_PREWARM_POLL_INTERVAL_MS = 100
|
|
38
|
+
# How long close waits for in-flight lease work to land before giving up
|
|
39
|
+
# on it. Bounded on purpose: a shutdown that hangs is worse than a hold
|
|
40
|
+
# the server expires at DEFAULT_LEASE_DURATION_MS.
|
|
41
|
+
SHUTDOWN_DRAIN_TIMEOUT_MS = 5000
|
|
42
|
+
|
|
43
|
+
# How many in-flight extends one caller will wait out before issuing its
|
|
44
|
+
# own. Two covers the case the single flight was written for: the flight a
|
|
45
|
+
# caller joins, and the follow-up another caller registers while it was
|
|
46
|
+
# waiting.
|
|
47
|
+
MAX_EXTEND_JOINS = 2
|
|
48
|
+
|
|
49
|
+
# Balance substituted for a fail-open evaluation: large enough that the
|
|
50
|
+
# credit gate always passes, and the same figure the other SDKs use so a
|
|
51
|
+
# shared vector can name it.
|
|
52
|
+
FAIL_OPEN_BALANCE = (2**53) - 1
|
|
53
|
+
|
|
54
|
+
# Where a credit hold lives for a check that passes usage.
|
|
55
|
+
# :client - local leases over DataStream.
|
|
56
|
+
# :server - one check-and-reserve API call per check.
|
|
57
|
+
# :auto - client when DataStream is enabled, server otherwise.
|
|
58
|
+
MODES = %i[client server auto].freeze
|
|
59
|
+
|
|
60
|
+
# What to do when a lease cannot be acquired or reserved against.
|
|
61
|
+
# :fail_open - re-run the engine with the credit balance assumed
|
|
62
|
+
# sufficient, so non-credit rules still apply.
|
|
63
|
+
# :fail_closed - deny, because the gate cannot gate.
|
|
64
|
+
FAILURE_MODES = %i[fail_open fail_closed].freeze
|
|
65
|
+
|
|
66
|
+
# Reads the current time. Every store and the lease manager take one so
|
|
67
|
+
# tests and the conformance runner can drive a virtual clock instead of
|
|
68
|
+
# wall time.
|
|
69
|
+
DEFAULT_CLOCK = -> { Time.now }
|
|
70
|
+
|
|
71
|
+
# Convert a caller-supplied mode to a symbol, accepting the hyphenated
|
|
72
|
+
# spellings the other SDKs use ("fail-open") so one config shape travels.
|
|
73
|
+
def self.normalize_symbol(value)
|
|
74
|
+
return nil if value.nil?
|
|
75
|
+
|
|
76
|
+
value.to_s.tr("-", "_").downcase.to_sym
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# How long stop waits for the sweeper to finish what it is doing before
|
|
80
|
+
# killing it. Long enough for a sweep already past its claim to land its
|
|
81
|
+
# refund, short enough that close stays prompt.
|
|
82
|
+
SWEEP_STOP_JOIN_MS = 100
|
|
83
|
+
|
|
84
|
+
# Resolve a caller's on_acquire_failure, or fail closed.
|
|
85
|
+
#
|
|
86
|
+
# An unrecognized value must not read as fail-open: the branches all test
|
|
87
|
+
# for :fail_closed, so a typo would quietly turn the safe default into the
|
|
88
|
+
# permissive one on every check. Name the value in the warning, since a
|
|
89
|
+
# silent downgrade of a gate is the thing worth telling someone about.
|
|
90
|
+
def self.resolve_failure_mode(value, logger = nil)
|
|
91
|
+
return :fail_closed if value.nil?
|
|
92
|
+
|
|
93
|
+
mode = normalize_symbol(value)
|
|
94
|
+
return mode if FAILURE_MODES.include?(mode)
|
|
95
|
+
|
|
96
|
+
logger&.warn(
|
|
97
|
+
"Unrecognized on_acquire_failure #{value.inspect}; expected one of " \
|
|
98
|
+
"#{FAILURE_MODES.join(" or ")}. Failing closed."
|
|
99
|
+
)
|
|
100
|
+
:fail_closed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Whether a caller-supplied quantity can size a credit hold. NaN is the
|
|
104
|
+
# dangerous case: it slips through every numeric comparison, and a NaN
|
|
105
|
+
# balance would approve every later reserve on a possibly shared lease.
|
|
106
|
+
def self.valid_quantity?(value)
|
|
107
|
+
value.is_a?(Numeric) && !value.to_f.nan? && value.to_f.finite? && value >= 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The local view of the one lease a (company, credit type) slot holds.
|
|
111
|
+
class LeaseEntry
|
|
112
|
+
attr_accessor :lease_id, :company_id, :credit_type_id, :granted_amount,
|
|
113
|
+
:local_remaining_credits, :expires_at
|
|
114
|
+
|
|
115
|
+
def initialize(lease_id:, company_id:, credit_type_id:, granted_amount:, expires_at:,
|
|
116
|
+
local_remaining_credits: nil)
|
|
117
|
+
@lease_id = lease_id
|
|
118
|
+
@company_id = company_id
|
|
119
|
+
@credit_type_id = credit_type_id
|
|
120
|
+
@granted_amount = granted_amount.to_f
|
|
121
|
+
@local_remaining_credits = (local_remaining_credits || granted_amount).to_f
|
|
122
|
+
@expires_at = expires_at
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def dup
|
|
126
|
+
LeaseEntry.new(
|
|
127
|
+
lease_id: @lease_id,
|
|
128
|
+
company_id: @company_id,
|
|
129
|
+
credit_type_id: @credit_type_id,
|
|
130
|
+
granted_amount: @granted_amount,
|
|
131
|
+
local_remaining_credits: @local_remaining_credits,
|
|
132
|
+
expires_at: @expires_at
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def expired?(now)
|
|
137
|
+
@expires_at.to_f <= now.to_f
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The post-debit balance a successful try_reserve returns, plus the id of
|
|
142
|
+
# the lease the credits actually came out of. The debit is not keyed by
|
|
143
|
+
# lease id, so the caller pins its reservation to this id and never to the
|
|
144
|
+
# one its acquire handed back.
|
|
145
|
+
ReserveResult = Struct.new(:balance, :lease_id)
|
|
146
|
+
|
|
147
|
+
# One credit hold carved out of a lease by a check. Returned to the caller
|
|
148
|
+
# from check and handed back to track_with_reservation.
|
|
149
|
+
class Reservation
|
|
150
|
+
attr_reader :id, :lease_id, :mode, :company_id, :credit_type_id, :event_subtype,
|
|
151
|
+
:quantity_reserved, :credits_reserved, :consumption_rate, :expires_at, :eval_ctx
|
|
152
|
+
|
|
153
|
+
def initialize(id:, lease_id:, company_id:, credit_type_id:, event_subtype:,
|
|
154
|
+
quantity_reserved:, credits_reserved:, consumption_rate:, expires_at:,
|
|
155
|
+
eval_ctx: {}, mode: nil)
|
|
156
|
+
@id = id
|
|
157
|
+
@lease_id = lease_id
|
|
158
|
+
# :server means the API holds the credits and the settling track event
|
|
159
|
+
# routes by reservation_id; nil (or :client) means the hold is a local
|
|
160
|
+
# carve-out of a lease.
|
|
161
|
+
@mode = mode
|
|
162
|
+
@company_id = company_id
|
|
163
|
+
@credit_type_id = credit_type_id
|
|
164
|
+
@event_subtype = event_subtype
|
|
165
|
+
@quantity_reserved = quantity_reserved.to_f
|
|
166
|
+
@credits_reserved = credits_reserved.to_f
|
|
167
|
+
@consumption_rate = consumption_rate.to_f
|
|
168
|
+
@expires_at = expires_at
|
|
169
|
+
@eval_ctx = eval_ctx || {}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def server_mode?
|
|
173
|
+
@mode == :server
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_h
|
|
177
|
+
{
|
|
178
|
+
id: @id,
|
|
179
|
+
lease_id: @lease_id,
|
|
180
|
+
mode: @mode,
|
|
181
|
+
company_id: @company_id,
|
|
182
|
+
credit_type_id: @credit_type_id,
|
|
183
|
+
event_subtype: @event_subtype,
|
|
184
|
+
quantity_reserved: @quantity_reserved,
|
|
185
|
+
credits_reserved: @credits_reserved,
|
|
186
|
+
consumption_rate: @consumption_rate,
|
|
187
|
+
expires_at: @expires_at,
|
|
188
|
+
eval_ctx: @eval_ctx
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Cast a usage onto the integer the wire carries. A hold can be sized from
|
|
194
|
+
# a fractional usage, but every quantity field on the API (the
|
|
195
|
+
# check-and-reserve ask, the preflight envelope, a track event) is an
|
|
196
|
+
# integer, and the generated models truncate a float onto it. A preflight
|
|
197
|
+
# asks an upper-bound question and a settle must not bill a partial unit
|
|
198
|
+
# as none, so a fraction rounds up in both directions.
|
|
199
|
+
# Float noise is shaved off before the rounding: (0.1 + 0.2) * 10 is
|
|
200
|
+
# 3.0000000000000004, and a bare ceil would bill that as 4.
|
|
201
|
+
ROUND_UP_TOLERANCE = 1e-9
|
|
202
|
+
|
|
203
|
+
def self.wire_quantity(value)
|
|
204
|
+
return value unless value.is_a?(Numeric)
|
|
205
|
+
return value if value.is_a?(Integer)
|
|
206
|
+
return value unless value.finite?
|
|
207
|
+
|
|
208
|
+
shaved = value - ROUND_UP_TOLERANCE
|
|
209
|
+
shaved.positive? ? shaved.ceil : value.ceil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# One shape for the matched entitlement whichever mode produced it.
|
|
213
|
+
#
|
|
214
|
+
# The WASM engine hands back a camelCase hash and the API hands back a
|
|
215
|
+
# generated model, so without this a caller reading result.entitlement
|
|
216
|
+
# would need one accessor for client mode and another for server mode.
|
|
217
|
+
# Both become a snake_case, symbol-keyed Hash matching the field names on
|
|
218
|
+
# Schematic::Types::FeatureEntitlement, and metric_reset_at is parsed to a
|
|
219
|
+
# Time so a caller can compare it without knowing which mode it came from.
|
|
220
|
+
def self.normalize_entitlement(raw)
|
|
221
|
+
return nil if raw.nil?
|
|
222
|
+
|
|
223
|
+
hash = raw.is_a?(Hash) ? raw : entitlement_to_h(raw)
|
|
224
|
+
return nil if hash.nil?
|
|
225
|
+
|
|
226
|
+
normalized = deep_snake_case(hash)
|
|
227
|
+
reset_at = normalized[:metric_reset_at]
|
|
228
|
+
normalized[:metric_reset_at] = parse_reset_at(reset_at) unless reset_at.nil?
|
|
229
|
+
normalized
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def self.entitlement_to_h(raw)
|
|
233
|
+
return raw.to_h if raw.respond_to?(:to_h)
|
|
234
|
+
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
private_class_method :entitlement_to_h
|
|
238
|
+
|
|
239
|
+
def self.parse_reset_at(value)
|
|
240
|
+
parse_api_time(value)
|
|
241
|
+
rescue StandardError
|
|
242
|
+
value
|
|
243
|
+
end
|
|
244
|
+
private_class_method :parse_reset_at
|
|
245
|
+
|
|
246
|
+
# The API's timestamps are UTC, but Time.iso8601 reads one that carries
|
|
247
|
+
# no offset as host-local time, which would shift a lease's expiry by the
|
|
248
|
+
# host's UTC offset. A timestamp with no zone is read as UTC instead.
|
|
249
|
+
def self.parse_api_time(value)
|
|
250
|
+
return value.getutc if value.is_a?(Time)
|
|
251
|
+
|
|
252
|
+
text = value.to_s
|
|
253
|
+
text += "Z" if text.include?("T") && !text.match?(/(?:Z|[+-]\d{2}(?::?\d{2})?)\z/i)
|
|
254
|
+
Time.iso8601(text).utc
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def self.deep_snake_case(value)
|
|
258
|
+
case value
|
|
259
|
+
when Hash
|
|
260
|
+
value.each_with_object({}) do |(key, inner), out|
|
|
261
|
+
out[key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym] = deep_snake_case(inner)
|
|
262
|
+
end
|
|
263
|
+
when Array
|
|
264
|
+
value.map { |inner| deep_snake_case(inner) }
|
|
265
|
+
else
|
|
266
|
+
value
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
private_class_method :deep_snake_case
|
|
270
|
+
|
|
271
|
+
# What a lease-aware check decided. `allowed` is what the caller gates on;
|
|
272
|
+
# `reservation` is present only when a hold was taken.
|
|
273
|
+
class CheckResult
|
|
274
|
+
attr_reader :allowed, :value, :reservation, :reason, :entitlement, :flag_key, :flag_id, :error
|
|
275
|
+
|
|
276
|
+
def initialize(allowed:, value:, reason:, flag_key:, reservation: nil, entitlement: nil,
|
|
277
|
+
flag_id: nil, error: nil)
|
|
278
|
+
@allowed = allowed
|
|
279
|
+
@value = value
|
|
280
|
+
@reservation = reservation
|
|
281
|
+
@reason = reason
|
|
282
|
+
@entitlement = Leases.normalize_entitlement(entitlement)
|
|
283
|
+
@flag_key = flag_key
|
|
284
|
+
@flag_id = flag_id
|
|
285
|
+
@error = error
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def allowed?
|
|
289
|
+
@allowed
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def to_h
|
|
293
|
+
{
|
|
294
|
+
allowed: @allowed,
|
|
295
|
+
value: @value,
|
|
296
|
+
reservation: @reservation&.to_h,
|
|
297
|
+
reason: @reason,
|
|
298
|
+
entitlement: @entitlement,
|
|
299
|
+
flag_key: @flag_key,
|
|
300
|
+
flag_id: @flag_id,
|
|
301
|
+
error: @error
|
|
302
|
+
}.compact
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The four resolvable knobs for one credit type, after overrides and
|
|
307
|
+
# defaults.
|
|
308
|
+
ResolvedLeaseConfig = Struct.new(:lease_duration_ms, :reservation_ttl_ms, :lease_size, :low_water_mark,
|
|
309
|
+
keyword_init: true)
|
|
310
|
+
|
|
311
|
+
# Resolve the knobs for one credit type: the credit type's override wins,
|
|
312
|
+
# then the client-wide config, then the default.
|
|
313
|
+
def self.resolve_config(config, credit_type_id)
|
|
314
|
+
config ||= {}
|
|
315
|
+
overrides = config[:overrides] || {}
|
|
316
|
+
override = overrides[credit_type_id] || overrides[credit_type_id.to_s] ||
|
|
317
|
+
overrides[credit_type_id.to_sym] || {}
|
|
318
|
+
ResolvedLeaseConfig.new(
|
|
319
|
+
lease_duration_ms: override[:default_lease_duration] || config[:default_lease_duration] ||
|
|
320
|
+
DEFAULT_LEASE_DURATION_MS,
|
|
321
|
+
reservation_ttl_ms: override[:default_reservation_ttl] || config[:default_reservation_ttl] ||
|
|
322
|
+
DEFAULT_RESERVATION_TTL_MS,
|
|
323
|
+
lease_size: override[:default_lease_size] || config[:default_lease_size] || DEFAULT_LEASE_SIZE,
|
|
324
|
+
low_water_mark: override[:low_water_mark] || config[:low_water_mark] || DEFAULT_LOW_WATER_MARK
|
|
325
|
+
)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|