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,477 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Schematic
|
|
6
|
+
module Credits
|
|
7
|
+
module Leases
|
|
8
|
+
# Everything a lease-bearing check needs. enqueue_flag_check_event reports
|
|
9
|
+
# a flag_check event for a check the lease path resolved itself, mirroring
|
|
10
|
+
# the plain check paths so lease-gated checks stay visible to flag-check
|
|
11
|
+
# analytics and company last-seen. Fallback exits do not call it: the
|
|
12
|
+
# plain check they delegate to enqueues its own.
|
|
13
|
+
CheckDeps = Struct.new(:lease_store, :reservations, :manager, :datastream, :logger, :clock,
|
|
14
|
+
:enqueue_flag_check_event, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# Read a field from an engine payload, which carries camelCase keys inside
|
|
17
|
+
# the entitlement even though the top-level result is normalized.
|
|
18
|
+
def self.field(hash, *names)
|
|
19
|
+
return nil if hash.nil?
|
|
20
|
+
|
|
21
|
+
names.each do |name|
|
|
22
|
+
value = hash[name] || hash[name.to_s]
|
|
23
|
+
return value unless value.nil?
|
|
24
|
+
end
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The preflight envelope for a client-side rules evaluation. With an
|
|
29
|
+
# event_subtype the quantity goes out as the event_usage pair so the
|
|
30
|
+
# engine matches it to that subtype's condition; without one it goes out
|
|
31
|
+
# as the generic usage knob. Public so the plain check path can thread the
|
|
32
|
+
# same preflight through when the lease path cannot run.
|
|
33
|
+
def self.build_preflight_options(options)
|
|
34
|
+
usage = options[:usage]
|
|
35
|
+
return nil if usage.nil?
|
|
36
|
+
|
|
37
|
+
# The preflight quantity is an integer on both seams (the engine
|
|
38
|
+
# envelope and the API's preflight body), and it asks an upper-bound
|
|
39
|
+
# question, so a fractional usage rounds up rather than gating on less
|
|
40
|
+
# usage than the operation is about to record.
|
|
41
|
+
quantity = Leases.wire_quantity(usage)
|
|
42
|
+
# A zero usage has no effect server-side, so sending a preflight for one
|
|
43
|
+
# would only cost the check its flag cache. A zero credit_cost would be
|
|
44
|
+
# different, saying free rather than absent, but this never emits one.
|
|
45
|
+
return nil if quantity.zero?
|
|
46
|
+
|
|
47
|
+
if options[:event_subtype]
|
|
48
|
+
{ event_usage: { event_subtype: options[:event_subtype], quantity: quantity } }
|
|
49
|
+
else
|
|
50
|
+
{ usage: quantity }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Drive a single lease-gated check.
|
|
55
|
+
#
|
|
56
|
+
# 1. Probe the engine once against the company's real balance, with no
|
|
57
|
+
# substitution and no preflight, and read the matched entitlement. A
|
|
58
|
+
# non-credit entitlement means there is nothing to lease, so defer to
|
|
59
|
+
# the plain check.
|
|
60
|
+
# 2. Acquire (or reuse) a lease for (company, credit id).
|
|
61
|
+
# 3. Reserve ceil(usage) x consumption_rate from it, atomically.
|
|
62
|
+
# 4. Re-run the engine against a company snapshot whose balance for that
|
|
63
|
+
# credit is the PRE-reservation local balance, with credit_cost set, so
|
|
64
|
+
# the engine evaluates the same arithmetic try_reserve just enforced.
|
|
65
|
+
# The hold only sticks if the engine allows.
|
|
66
|
+
def self.check_with_lease(deps, key, eval_ctx, options, &fallback)
|
|
67
|
+
Check.new(deps, key, eval_ctx, options, fallback).run
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The check flow, as an object so its steps can pass state without
|
|
71
|
+
# threading a dozen arguments through every helper.
|
|
72
|
+
class Check
|
|
73
|
+
def initialize(deps, key, eval_ctx, options, fallback)
|
|
74
|
+
@deps = deps
|
|
75
|
+
@key = key
|
|
76
|
+
@eval_ctx = eval_ctx || {}
|
|
77
|
+
@options = options || {}
|
|
78
|
+
@fallback = fallback
|
|
79
|
+
@logger = deps.logger
|
|
80
|
+
@clock = deps.clock || DEFAULT_CLOCK
|
|
81
|
+
@on_failure = Leases.resolve_failure_mode(@options[:on_acquire_failure], @logger)
|
|
82
|
+
# Fixed now, not at each wait: a deadline taken when a join starts
|
|
83
|
+
# would let a check spend its acquire and reserve time and then its
|
|
84
|
+
# whole timeout again behind someone else's extend.
|
|
85
|
+
@deadline = Leases.join_deadline(request_options)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def run
|
|
89
|
+
guard = check_guards
|
|
90
|
+
return guard if guard
|
|
91
|
+
|
|
92
|
+
resolved = resolve_entitlement
|
|
93
|
+
return @fallback.call if resolved.nil?
|
|
94
|
+
|
|
95
|
+
@credit_id, @consumption_rate, @event_subtype = resolved
|
|
96
|
+
# Whole event units: a fraction of an event is not something the
|
|
97
|
+
# server bills, so the hold rounds up to what the settle will charge.
|
|
98
|
+
# Sizing it on the raw quantity would move the local ledger by less
|
|
99
|
+
# than the Track event, and the two would drift apart over a session.
|
|
100
|
+
# Rounded through wire_quantity, the one the Track event's quantity
|
|
101
|
+
# goes through, so the hold, the local debit and the billed figure
|
|
102
|
+
# cannot disagree over a float that is a hair above a whole unit.
|
|
103
|
+
@credit_cost = Leases.wire_quantity(@options[:usage]) * @consumption_rate
|
|
104
|
+
|
|
105
|
+
lease = @deps.manager.acquire_if_needed(@company[:id], @credit_id, request_options, deadline: @deadline)
|
|
106
|
+
return failure("lease_acquire_failed") if lease.nil?
|
|
107
|
+
|
|
108
|
+
reserve = reserve_credits
|
|
109
|
+
return reserve if reserve.is_a?(CheckResult)
|
|
110
|
+
|
|
111
|
+
reservation = register_reservation(reserve)
|
|
112
|
+
persisted = persist(reservation, reserve)
|
|
113
|
+
return persisted if persisted.is_a?(CheckResult)
|
|
114
|
+
|
|
115
|
+
gate(reservation, reserve)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Guards, in order: a malformed usage never reaches the stores; zero
|
|
121
|
+
# usage has nothing to reserve; and without a datastream, a cached flag,
|
|
122
|
+
# or a resolvable company there is no local evaluation to gate with.
|
|
123
|
+
def check_guards
|
|
124
|
+
usage = @options[:usage]
|
|
125
|
+
# NaN slips through every numeric comparison, so a single NaN debit
|
|
126
|
+
# would poison the (possibly shared) lease balance into approving
|
|
127
|
+
# every later reserve. The stores guard too, but resolve it here
|
|
128
|
+
# through the caller's failure contract rather than letting it surface
|
|
129
|
+
# as an opaque reserve failure.
|
|
130
|
+
unless Leases.valid_quantity?(usage)
|
|
131
|
+
@logger.error(
|
|
132
|
+
"Lease check: invalid usage #{usage.inspect} for flag #{@key}, must be a finite non-negative number"
|
|
133
|
+
)
|
|
134
|
+
return emit(static_failure_result("invalid_usage", nil))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if usage.zero?
|
|
138
|
+
@logger.debug("Lease check: usage is 0 for flag #{@key}, nothing to reserve, using plain check")
|
|
139
|
+
return @fallback.call
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
return @fallback.call if datastream_unavailable?
|
|
143
|
+
return @fallback.call if load_flag.nil?
|
|
144
|
+
return @fallback.call unless entities_resolved?
|
|
145
|
+
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def datastream_unavailable?
|
|
150
|
+
return false if @deps.datastream
|
|
151
|
+
|
|
152
|
+
@logger.debug("Credit-lease check requested without datastream, falling back to plain check")
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def load_flag
|
|
157
|
+
@flag = begin
|
|
158
|
+
@deps.datastream.get_flag(@key)
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
@logger.warn("Lease check: failed to load flag #{@key}: #{e.message}")
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
@logger.debug("Lease check: no cached flag for #{@key}, falling back") if @flag.nil?
|
|
164
|
+
@flag
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Resolve company and user the way a plain datastream check does. An
|
|
168
|
+
# evaluation with a missing entity is not an option: a nil user would
|
|
169
|
+
# silently skip user-targeted rules and overrides, so a named entity
|
|
170
|
+
# that cannot be resolved falls back to the plain check, which has its
|
|
171
|
+
# own degradation story.
|
|
172
|
+
def entities_resolved?
|
|
173
|
+
company_keys = @eval_ctx[:company] || @eval_ctx["company"]
|
|
174
|
+
if company_keys.nil? || company_keys.empty?
|
|
175
|
+
@logger.debug("Lease check: no company on eval context, falling back")
|
|
176
|
+
return false
|
|
177
|
+
end
|
|
178
|
+
@company = fetch_entity("company") { @deps.datastream.get_company(company_keys) }
|
|
179
|
+
return false if @company.nil?
|
|
180
|
+
|
|
181
|
+
user_keys = @eval_ctx[:user] || @eval_ctx["user"]
|
|
182
|
+
return true if user_keys.nil? || user_keys.empty?
|
|
183
|
+
|
|
184
|
+
@user = fetch_entity("user") { @deps.datastream.get_user(user_keys) }
|
|
185
|
+
!@user.nil?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def fetch_entity(kind)
|
|
189
|
+
yield
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
@logger.debug("Lease check: #{kind} fetch failed (#{e.message}), falling back")
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# One probe against the real balance surfaces the matched entitlement,
|
|
196
|
+
# which names the credit directly and lets a non-credit grant skip the
|
|
197
|
+
# lease round trip entirely. The probe omits preflight on purpose:
|
|
198
|
+
# charging a cost against the lease-depleted server balance could fail
|
|
199
|
+
# the credit condition, drop the engine to a lower-priority rule, and
|
|
200
|
+
# hide the very entitlement being identified.
|
|
201
|
+
def resolve_entitlement
|
|
202
|
+
probe = probe_entitlement
|
|
203
|
+
return nil if probe.nil?
|
|
204
|
+
|
|
205
|
+
entitlement = probe[:entitlement]
|
|
206
|
+
value_type = Leases.field(entitlement, :valueType, :value_type)
|
|
207
|
+
unless value_type == "credit"
|
|
208
|
+
# A boolean or override grant, a numeric allocation, unlimited, or
|
|
209
|
+
# simply not entitled. The feature resolves without drawing a
|
|
210
|
+
# credit, so skip the lease round-trip and let the plain check,
|
|
211
|
+
# which is preflight-aware, decide.
|
|
212
|
+
@logger.debug(
|
|
213
|
+
"Lease check: flag #{@key} matched a non-credit entitlement " \
|
|
214
|
+
"(value_type=#{value_type || "<none>"}), falling back to plain check, no reservation"
|
|
215
|
+
)
|
|
216
|
+
return nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
credit_id = Leases.field(entitlement, :creditId, :credit_id)
|
|
220
|
+
consumption_rate = (Leases.field(entitlement, :consumptionRate, :consumption_rate) || 0).to_f
|
|
221
|
+
# The caller's explicit subtype wins; otherwise the entitlement names
|
|
222
|
+
# the metered event. The reservation settles into a track event named
|
|
223
|
+
# by this subtype, so a credit entitlement with neither a resolvable
|
|
224
|
+
# subtype nor a positive rate cannot be billed and is ungateable.
|
|
225
|
+
subtype = @options[:event_subtype] || Leases.field(entitlement, :eventSubtype, :event_subtype)
|
|
226
|
+
if credit_id.nil? || consumption_rate <= 0 || subtype.nil?
|
|
227
|
+
@logger.debug(
|
|
228
|
+
"Lease check: flag #{@key} credit entitlement is incomplete " \
|
|
229
|
+
"(credit_id=#{credit_id || "<none>"}, consumption_rate=#{consumption_rate}, " \
|
|
230
|
+
"subtype=#{subtype || "<none>"}), falling back"
|
|
231
|
+
)
|
|
232
|
+
return nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
[credit_id, consumption_rate, subtype]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def probe_entitlement
|
|
239
|
+
evaluate(@company, nil)
|
|
240
|
+
rescue StandardError => e
|
|
241
|
+
# The probe is a resolution step, not the gate, so a failure means the
|
|
242
|
+
# credit could not be resolved. Defer to the plain check rather than
|
|
243
|
+
# hard-denying. No reservation exists yet, so nothing to cancel.
|
|
244
|
+
@logger.warn("Lease check: entitlement probe failed for flag #{@key} (#{e.message}), falling back")
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# try_reserve is the atomic gate: check and debit in one step, returning
|
|
249
|
+
# the post-debit balance (so the pre-debit figure follows without a
|
|
250
|
+
# second store read) AND the id of the lease it charged. That id, not
|
|
251
|
+
# the acquired one, is what the reservation is pinned to: the debit is
|
|
252
|
+
# not keyed by lease, so the slot's lease may have been replaced since
|
|
253
|
+
# the acquire, and the window spans the extend awaited below.
|
|
254
|
+
def reserve_credits
|
|
255
|
+
reserve = @deps.lease_store.try_reserve(@company[:id], @credit_id, @credit_cost)
|
|
256
|
+
if reserve.nil?
|
|
257
|
+
# The lease has less than credit_cost left locally. Passing
|
|
258
|
+
# credit_cost extends even when the ratio is still above the low
|
|
259
|
+
# water mark, which a single large request needs.
|
|
260
|
+
@deps.manager.maybe_extend_in_background(@company[:id], @credit_id, @credit_cost,
|
|
261
|
+
request_options, deadline: @deadline)&.join
|
|
262
|
+
reserve = @deps.lease_store.try_reserve(@company[:id], @credit_id, @credit_cost)
|
|
263
|
+
end
|
|
264
|
+
return failure("insufficient_lease_balance") if reserve.nil?
|
|
265
|
+
|
|
266
|
+
reserve
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
@logger.error("Lease check: reserve against #{@company[:id]}/#{@credit_id} failed: #{e.message}")
|
|
269
|
+
failure("lease_store_error")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def register_reservation(reserve)
|
|
273
|
+
resolved = @deps.manager.resolve_config(@credit_id)
|
|
274
|
+
Reservation.new(
|
|
275
|
+
id: SecureRandom.uuid,
|
|
276
|
+
# The lease the debit actually landed on, which may not be the one
|
|
277
|
+
# the acquire handed back. Pinning the acquired id instead would
|
|
278
|
+
# send the settle refund, the sweep refund, and the track event's
|
|
279
|
+
# lease_id to a lease that was never charged.
|
|
280
|
+
lease_id: reserve.lease_id,
|
|
281
|
+
company_id: @company[:id],
|
|
282
|
+
credit_type_id: @credit_id,
|
|
283
|
+
event_subtype: @event_subtype,
|
|
284
|
+
quantity_reserved: @options[:usage],
|
|
285
|
+
credits_reserved: @credit_cost,
|
|
286
|
+
consumption_rate: @consumption_rate,
|
|
287
|
+
expires_at: @clock.call + (resolved.reservation_ttl_ms / 1000.0),
|
|
288
|
+
eval_ctx: @eval_ctx
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Recorded between the debit and the gate, so a crash leaves a sweepable
|
|
293
|
+
# reservation rather than credits stranded until the lease expires. The
|
|
294
|
+
# window it cannot cover is the gap before this call, which has no I/O
|
|
295
|
+
# in it and leaks at most credit_cost until that expiry.
|
|
296
|
+
def persist(reservation, reserve)
|
|
297
|
+
@deps.reservations.add(reservation)
|
|
298
|
+
nil
|
|
299
|
+
rescue StandardError => e
|
|
300
|
+
@logger.error("Lease check: failed to persist reservation #{reservation.id}: #{e.message}")
|
|
301
|
+
undo_debit(reservation, reserve)
|
|
302
|
+
failure("lease_store_error")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Undo the local debit so the credits are not stranded until lease
|
|
306
|
+
# expiry. consume claims whatever slice of the add made it to the store
|
|
307
|
+
# and refunds it; if nothing was persisted, refund the debit directly.
|
|
308
|
+
# Both are pinned to the lease the debit landed on. If even the undo
|
|
309
|
+
# fails, accept the bounded leak: the slice is reclaimed when the lease
|
|
310
|
+
# expires server-side, which beats risking a double refund.
|
|
311
|
+
def undo_debit(reservation, reserve)
|
|
312
|
+
undone = @deps.reservations.consume(reservation.id, 0)
|
|
313
|
+
@deps.lease_store.refund(@company[:id], @credit_id, @credit_cost, reserve.lease_id) if undone.nil?
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
@logger.warn(
|
|
316
|
+
"Lease check: could not undo local debit for #{reservation.id} (#{e.message}); " \
|
|
317
|
+
"the slice is reclaimed at lease expiry"
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The engine gates against the lease's local view, not the server's
|
|
322
|
+
# balance, so it re-checks the arithmetic try_reserve just enforced plus
|
|
323
|
+
# every non-credit rule. The pre-reservation figure is the atomic
|
|
324
|
+
# reserve's own post-debit balance plus what it debited, so it is exact
|
|
325
|
+
# as of the debit with no read race.
|
|
326
|
+
def gate(reservation, reserve)
|
|
327
|
+
pre_reservation = reserve.balance + @credit_cost
|
|
328
|
+
substituted = substitute_credit_balance(@company, @credit_id, pre_reservation)
|
|
329
|
+
begin
|
|
330
|
+
result = evaluate(substituted, { credit_cost: { @credit_id => @credit_cost } })
|
|
331
|
+
rescue StandardError => e
|
|
332
|
+
@logger.error("Lease check: rules engine evaluation failed: #{e.message}")
|
|
333
|
+
# Cancel the hold, then resolve the mode statically: the engine
|
|
334
|
+
# itself just failed, so a fail-open re-evaluation is impossible.
|
|
335
|
+
cancel_reservation(reservation)
|
|
336
|
+
return emit(static_failure_result("wasm_error: #{e.message}", @flag), engine_ids(nil))
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
ids = engine_ids(result)
|
|
340
|
+
# A nil verdict denies here rather than standing in the caller's
|
|
341
|
+
# default, which is what the plain check path does with the same nil.
|
|
342
|
+
# The difference is deliberate: this branch holds credits, and the
|
|
343
|
+
# default exists to answer a flag nothing evaluated, not to release a
|
|
344
|
+
# hold the engine declined to approve.
|
|
345
|
+
unless result[:value]
|
|
346
|
+
cancel_reservation(reservation)
|
|
347
|
+
return emit(
|
|
348
|
+
CheckResult.new(
|
|
349
|
+
allowed: false, value: false, reason: result[:reason] || "denied_by_engine",
|
|
350
|
+
entitlement: result[:entitlement], flag_key: result[:flag_key] || @key, flag_id: result[:flag_id]
|
|
351
|
+
), ids
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The engine allowed against the substituted lease balance, so the
|
|
356
|
+
# hold stays. No rule-match disambiguation is needed: the probe
|
|
357
|
+
# already established that this company's matched entitlement is the
|
|
358
|
+
# credit one, so an override or boolean grant would have skipped the
|
|
359
|
+
# reserve path. Bumping only the credit balance cannot make a
|
|
360
|
+
# different rule match here.
|
|
361
|
+
|
|
362
|
+
# Fire and forget the low-water-mark refresh now that we have debited.
|
|
363
|
+
@deps.manager.maybe_extend_in_background(@company[:id], @credit_id)
|
|
364
|
+
|
|
365
|
+
emit(
|
|
366
|
+
CheckResult.new(
|
|
367
|
+
allowed: true, value: true, reservation: reservation,
|
|
368
|
+
reason: result[:reason] || "lease_reserved", entitlement: result[:entitlement],
|
|
369
|
+
flag_key: result[:flag_key] || @key, flag_id: result[:flag_id]
|
|
370
|
+
), ids
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Best-effort cancel: claims the record and refunds its full hold.
|
|
375
|
+
def cancel_reservation(reservation)
|
|
376
|
+
@deps.reservations.consume(reservation.id, 0)
|
|
377
|
+
rescue StandardError => e
|
|
378
|
+
@logger.warn(
|
|
379
|
+
"Lease check: failed to cancel reservation #{reservation.id} (#{e.message}); " \
|
|
380
|
+
"its hold is reclaimed by the sweeper or at lease expiry"
|
|
381
|
+
)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Every can't-gate outcome funnels through here. fail-open means assume
|
|
385
|
+
# the credits are there, NOT skip evaluation: the engine still runs with
|
|
386
|
+
# the balance substituted to an effectively unlimited value, so a company
|
|
387
|
+
# that is not entitled stays denied even with the lease backend down.
|
|
388
|
+
# Only if that evaluation itself fails does this fall back to a blanket
|
|
389
|
+
# allow.
|
|
390
|
+
def failure(reason)
|
|
391
|
+
result =
|
|
392
|
+
if @on_failure == :fail_closed
|
|
393
|
+
static_failure_result(reason, @flag)
|
|
394
|
+
else
|
|
395
|
+
fail_open_result(reason)
|
|
396
|
+
end
|
|
397
|
+
emit(result, { company_id: @company&.dig(:id), user_id: @user&.dig(:id) })
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def fail_open_result(reason)
|
|
401
|
+
substituted = substitute_credit_balance(@company, @credit_id, FAIL_OPEN_BALANCE)
|
|
402
|
+
result = evaluate(substituted, Leases.build_preflight_options(@options))
|
|
403
|
+
CheckResult.new(
|
|
404
|
+
allowed: result[:value], value: result[:value],
|
|
405
|
+
reason: "#{result[:reason] || "evaluated"} (#{reason}_fail_open)",
|
|
406
|
+
entitlement: result[:entitlement], flag_key: result[:flag_key] || @key,
|
|
407
|
+
flag_id: result[:flag_id] || @flag&.dig(:id), error: reason
|
|
408
|
+
)
|
|
409
|
+
rescue StandardError => e
|
|
410
|
+
@logger.warn("Lease check: fail-open evaluation failed (#{e.message}); allowing")
|
|
411
|
+
static_failure_result(reason, @flag)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# A mode resolved without an engine evaluation: deny for fail-closed,
|
|
415
|
+
# blanket allow for fail-open. Used when the engine itself is the thing
|
|
416
|
+
# that failed, and as the fallback when a fail-open evaluation errors.
|
|
417
|
+
def static_failure_result(reason, flag)
|
|
418
|
+
if @on_failure == :fail_closed
|
|
419
|
+
CheckResult.new(allowed: false, value: false, reason: reason, flag_key: @key,
|
|
420
|
+
flag_id: flag&.dig(:id), error: reason)
|
|
421
|
+
else
|
|
422
|
+
CheckResult.new(allowed: true, value: true, reason: "#{reason}_fail_open", flag_key: @key,
|
|
423
|
+
flag_id: flag&.dig(:id), error: reason)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def evaluate(company, options)
|
|
428
|
+
@deps.datastream.check_flag_with_options(@flag, company, @user, options)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def substitute_credit_balance(company, credit_id, balance)
|
|
432
|
+
substituted = company.dup
|
|
433
|
+
balances = (company[:credit_balances] || company["credit_balances"] || {}).dup
|
|
434
|
+
# The cache symbolizes keys, so a balance may be filed under either
|
|
435
|
+
# spelling. Replace whichever is there so the engine sees one value.
|
|
436
|
+
balances.delete(credit_id.to_sym)
|
|
437
|
+
balances.delete(credit_id.to_s)
|
|
438
|
+
balances[credit_id] = balance
|
|
439
|
+
substituted[:credit_balances] = balances
|
|
440
|
+
substituted
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def engine_ids(result)
|
|
444
|
+
{
|
|
445
|
+
company_id: (result && result[:company_id]) || @company&.dig(:id),
|
|
446
|
+
user_id: (result && result[:user_id]) || @user&.dig(:id),
|
|
447
|
+
rule_id: result && result[:rule_id]
|
|
448
|
+
}
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Thread the caller's per-check timeout to the lease wire calls the same
|
|
452
|
+
# way the fallback path threads it to a plain check.
|
|
453
|
+
def request_options
|
|
454
|
+
return {} if @options[:timeout_ms].nil?
|
|
455
|
+
|
|
456
|
+
{ timeout_in_seconds: @options[:timeout_ms] / 1000.0 }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def emit(result, ids = {})
|
|
460
|
+
@deps.enqueue_flag_check_event&.call(
|
|
461
|
+
flag_key: result.flag_key,
|
|
462
|
+
value: result.value,
|
|
463
|
+
reason: result.reason,
|
|
464
|
+
error: result.error,
|
|
465
|
+
flag_id: result.flag_id,
|
|
466
|
+
company_id: ids[:company_id],
|
|
467
|
+
user_id: ids[:user_id],
|
|
468
|
+
rule_id: ids[:rule_id],
|
|
469
|
+
req_company: @eval_ctx[:company] || @eval_ctx["company"],
|
|
470
|
+
req_user: @eval_ctx[:user] || @eval_ctx["user"]
|
|
471
|
+
)
|
|
472
|
+
result
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|