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,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Schematic
|
|
6
|
+
module Credits
|
|
7
|
+
module Leases
|
|
8
|
+
# Redis-backed reservation table. Each reservation is a hash, indexed by
|
|
9
|
+
# expires_at in a sorted set so the sweeper can pop expired entries in
|
|
10
|
+
# O(log n), and by (company, credit) so a balance display can sum a
|
|
11
|
+
# tenant's open holds.
|
|
12
|
+
#
|
|
13
|
+
# Every mutation is a single-key operation or single-key Lua, so the store
|
|
14
|
+
# is correct on standalone and clustered Redis alike: the unspent-slice
|
|
15
|
+
# refund is delegated to the lease store rather than reaching across to
|
|
16
|
+
# the lease hash inside a multi-key script.
|
|
17
|
+
class RedisReservationStore
|
|
18
|
+
DEFAULT_KEY_PREFIX = "schematic:"
|
|
19
|
+
RES_KEY_NAMESPACE = "credit-reservation:"
|
|
20
|
+
# Sorted set scoring open reservations by expires_at. Members encode the
|
|
21
|
+
# full (company, credit, id) tuple so the sweeper can clean the
|
|
22
|
+
# per-tenant hash even after the reservation hash has TTL-evicted, at
|
|
23
|
+
# which point the claim returns nil and cannot report company or credit;
|
|
24
|
+
# otherwise the orphaned field would permanently inflate reserved_credits.
|
|
25
|
+
RES_INDEX_KEY = "credit-reservations:byExpiry"
|
|
26
|
+
# Per-(company, credit) index of open reservations, one hash of
|
|
27
|
+
# reservationId to creditsReserved. reserved_credits then reads a whole
|
|
28
|
+
# tenant's holds with ONE HGETALL (single key, Cluster-safe) and sums the
|
|
29
|
+
# values. The hash also IS the source of truth for the sum: a field
|
|
30
|
+
# exists iff its reservation is open and unrefunded.
|
|
31
|
+
RES_BYCREDIT_NAMESPACE = "credit-reservations:byCredit:"
|
|
32
|
+
# Buffer past expires_at before Redis auto-evicts the row, so the
|
|
33
|
+
# sweeper has a window to refund.
|
|
34
|
+
RES_TTL_GRACE_MS = 30_000
|
|
35
|
+
# The delimiter is absent from Schematic ids and from the UUID
|
|
36
|
+
# reservation id.
|
|
37
|
+
MEMBER_DELIMITER = "|"
|
|
38
|
+
# Page size for the sweeper's ZRANGEBYSCORE. Without a limit, a backlog
|
|
39
|
+
# of expired holds would come back as one giant reply on every tick.
|
|
40
|
+
SWEEP_BATCH_SIZE = 256
|
|
41
|
+
# Upper bound on pages per tick. Keeps one sweep's work bounded and
|
|
42
|
+
# guards against an endless loop if a zrem persistently fails. Anything
|
|
43
|
+
# left over is picked up next tick.
|
|
44
|
+
MAX_SWEEP_BATCHES = 16
|
|
45
|
+
|
|
46
|
+
# Atomic claim: read the reservation hash and delete it in one step,
|
|
47
|
+
# returning its fields, or nil if it was already gone. One key, so it is
|
|
48
|
+
# Cluster-safe. The atomic read-then-delete is what makes consume
|
|
49
|
+
# exactly-once: of two racing callers only one gets the fields back and
|
|
50
|
+
# proceeds to refund. The refund is a separate single-key op; a crash in
|
|
51
|
+
# the gap leaves the unspent slice held on the lease until the lease
|
|
52
|
+
# itself expires, never double-refunded.
|
|
53
|
+
CLAIM_SCRIPT = Script.new(<<~LUA)
|
|
54
|
+
|
|
55
|
+
local raw = redis.call('HGETALL', KEYS[1])
|
|
56
|
+
if #raw == 0 then return nil end
|
|
57
|
+
redis.call('DEL', KEYS[1])
|
|
58
|
+
return raw
|
|
59
|
+
LUA
|
|
60
|
+
|
|
61
|
+
def initialize(client:, lease_store:, sweep_interval_ms: DEFAULT_SWEEP_INTERVAL_MS, key_prefix: nil,
|
|
62
|
+
clock: DEFAULT_CLOCK, logger: nil)
|
|
63
|
+
@client = client
|
|
64
|
+
@lease_store = lease_store
|
|
65
|
+
@sweep_interval_ms = sweep_interval_ms
|
|
66
|
+
@key_prefix = key_prefix || DEFAULT_KEY_PREFIX
|
|
67
|
+
@clock = clock
|
|
68
|
+
@logger = logger
|
|
69
|
+
@mutex = Mutex.new
|
|
70
|
+
@sweep_thread = nil
|
|
71
|
+
@stopped = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add(reservation)
|
|
75
|
+
expires_ms = millis(reservation.expires_at)
|
|
76
|
+
key = hash_key(reservation.id)
|
|
77
|
+
fields = [
|
|
78
|
+
"id", reservation.id,
|
|
79
|
+
"leaseId", reservation.lease_id,
|
|
80
|
+
"companyId", reservation.company_id,
|
|
81
|
+
"creditTypeId", reservation.credit_type_id,
|
|
82
|
+
"eventSubtype", reservation.event_subtype.to_s,
|
|
83
|
+
"quantityReserved", reservation.quantity_reserved.to_s,
|
|
84
|
+
"creditsReserved", reservation.credits_reserved.to_s,
|
|
85
|
+
"consumptionRate", reservation.consumption_rate.to_s,
|
|
86
|
+
"expiresAt", expires_ms.to_s,
|
|
87
|
+
"evalCtx", JSON.generate(reservation.eval_ctx)
|
|
88
|
+
]
|
|
89
|
+
ttl_at = expires_ms + RES_TTL_GRACE_MS
|
|
90
|
+
# The hash and its expiry go out as one MULTI/EXEC. Written
|
|
91
|
+
# separately, a crash in the gap leaves a reservation row with no TTL:
|
|
92
|
+
# once the sweeper drops its index entry, nothing points at the row
|
|
93
|
+
# and nothing reaps it, so it sits in Redis for good. Same commands,
|
|
94
|
+
# same key, same fields, so what other SDKs read is unchanged, and
|
|
95
|
+
# both commands touch the one key, so this is Cluster-safe.
|
|
96
|
+
if @client.respond_to?(:multi)
|
|
97
|
+
@client.multi do |tx|
|
|
98
|
+
tx.hset(key, *fields)
|
|
99
|
+
tx.pexpireat(key, ttl_at)
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
@client.hset(key, *fields)
|
|
103
|
+
@client.pexpireat(key, ttl_at)
|
|
104
|
+
end
|
|
105
|
+
# The two indexes (expiry zset for the sweeper, per-tenant hash for
|
|
106
|
+
# reserved_credits) only depend on the hash existing, so they stay
|
|
107
|
+
# outside the transaction: their keys hash to other slots. A partial
|
|
108
|
+
# failure here at worst leaves an un-indexed reservation that the TTL
|
|
109
|
+
# reaps, never a double spend.
|
|
110
|
+
@client.zadd(index_key, expires_ms, encode_member(reservation.company_id, reservation.credit_type_id,
|
|
111
|
+
reservation.id))
|
|
112
|
+
@client.hset(by_credit_key(reservation.company_id, reservation.credit_type_id), reservation.id,
|
|
113
|
+
reservation.credits_reserved.to_s)
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get(id)
|
|
118
|
+
raw = @client.hgetall(hash_key(id))
|
|
119
|
+
return nil if raw.nil? || raw["id"].nil?
|
|
120
|
+
|
|
121
|
+
decode_reservation(raw)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Sum a tenant's open holds with a single HGETALL on the per-tenant
|
|
125
|
+
# index hash: one round trip, one key, no per-reservation fan-out. A
|
|
126
|
+
# field is present iff its reservation is open and unrefunded, so the
|
|
127
|
+
# sum is exact.
|
|
128
|
+
def reserved_credits(company_id, credit_type_id)
|
|
129
|
+
by_credit = begin
|
|
130
|
+
@client.hgetall(by_credit_key(company_id, credit_type_id))
|
|
131
|
+
rescue StandardError
|
|
132
|
+
{}
|
|
133
|
+
end
|
|
134
|
+
(by_credit || {}).values.sum(&:to_f)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def consume(id, credits_consumed)
|
|
138
|
+
# Atomically claim the reservation hash. Only one caller wins; a
|
|
139
|
+
# duplicate or racing consume gets nil.
|
|
140
|
+
claimed = CLAIM_SCRIPT.call(@client, keys: [hash_key(id)], argv: [])
|
|
141
|
+
raw = decode_raw_array(claimed)
|
|
142
|
+
return nil if raw.nil? || raw["id"].nil?
|
|
143
|
+
|
|
144
|
+
company_id = raw["companyId"]
|
|
145
|
+
credit_type_id = raw["creditTypeId"]
|
|
146
|
+
reserved = raw["creditsReserved"].to_f
|
|
147
|
+
|
|
148
|
+
# Index cleanup, single-key ops. The credits leave the per-tenant hash
|
|
149
|
+
# BEFORE the refund below so the lease (local remaining plus this
|
|
150
|
+
# hash) never transiently double-counts the slice, and before the
|
|
151
|
+
# expiry index because that index is the only way the sweeper reaches
|
|
152
|
+
# a surviving field: dropping the index first and then failing on the
|
|
153
|
+
# field would inflate reserved_credits for that tenant forever.
|
|
154
|
+
swallow { @client.hdel(by_credit_key(company_id, credit_type_id), id) }
|
|
155
|
+
swallow { @client.zrem(index_key, encode_member(company_id, credit_type_id, id)) }
|
|
156
|
+
|
|
157
|
+
consumed = credits_consumed.clamp(0, reserved)
|
|
158
|
+
refund = reserved - consumed
|
|
159
|
+
# A hold that cannot name its lease is not refundable: the refund
|
|
160
|
+
# script reads an empty pin as no pin and would credit whichever lease
|
|
161
|
+
# holds the slot now, possibly a successor. The slice comes back when
|
|
162
|
+
# the lease expires instead, the same call the per-process store makes.
|
|
163
|
+
if refund.positive? && !raw["leaseId"].to_s.empty?
|
|
164
|
+
# Delegated to the lease store, which owns the lease hash, so the
|
|
165
|
+
# cross-key write stays out of a single Lua script. Pinned to the
|
|
166
|
+
# reservation's leaseId so a hold carved out of an expired lease
|
|
167
|
+
# cannot inflate a successor lease's balance.
|
|
168
|
+
@lease_store.refund(company_id, credit_type_id, refund, raw["leaseId"])
|
|
169
|
+
end
|
|
170
|
+
consumed
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def start_sweep
|
|
174
|
+
@mutex.synchronize do
|
|
175
|
+
# alive?, not presence: a process that forks after start (Puma or
|
|
176
|
+
# Unicorn with preload) hands the child a thread object whose thread
|
|
177
|
+
# did not survive the fork, and the child would never sweep.
|
|
178
|
+
return if @sweep_thread&.alive? || @stopped
|
|
179
|
+
|
|
180
|
+
interval = @sweep_interval_ms.to_f / 1000.0
|
|
181
|
+
@sweep_thread = Thread.new do
|
|
182
|
+
loop do
|
|
183
|
+
sleep(interval)
|
|
184
|
+
break if @stopped
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
sweep_expired
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
@logger&.debug("Reservation sweep failed: #{e.message}")
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
@sweep_thread.abort_on_exception = false
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def sweep_expired(now = nil)
|
|
198
|
+
cutoff = millis(now || @clock.call)
|
|
199
|
+
swept = 0
|
|
200
|
+
# Page through expired members rather than fetching them all at once.
|
|
201
|
+
# Each processed member is removed below, so re-reading at offset 0
|
|
202
|
+
# advances through the backlog.
|
|
203
|
+
MAX_SWEEP_BATCHES.times do
|
|
204
|
+
expired = @client.zrangebyscore(index_key, 0, cutoff, limit: [0, SWEEP_BATCH_SIZE]) || []
|
|
205
|
+
break if expired.empty?
|
|
206
|
+
|
|
207
|
+
expired.each { |member| swept += 1 if member_refunded?(member) }
|
|
208
|
+
break if expired.size < SWEEP_BATCH_SIZE
|
|
209
|
+
end
|
|
210
|
+
swept
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def stop
|
|
214
|
+
thread = @mutex.synchronize do
|
|
215
|
+
@stopped = true
|
|
216
|
+
thread = @sweep_thread
|
|
217
|
+
@sweep_thread = nil
|
|
218
|
+
thread
|
|
219
|
+
end
|
|
220
|
+
return nil if thread.nil?
|
|
221
|
+
|
|
222
|
+
# A sweep past its claim has deleted the reservation but not yet
|
|
223
|
+
# refunded the lease, and killing it there strands the unspent slice
|
|
224
|
+
# until the lease expires. Give it a bounded moment to land that
|
|
225
|
+
# refund, then kill: the loop checks @stopped as soon as its sleep
|
|
226
|
+
# ends, so a parked sweeper costs at most this wait.
|
|
227
|
+
thread.kill unless thread.join(SWEEP_STOP_JOIN_MS / 1000.0)
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def size
|
|
232
|
+
@client.zcard(index_key)
|
|
233
|
+
rescue StandardError
|
|
234
|
+
0
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
def member_refunded?(member)
|
|
240
|
+
decoded = decode_member(member)
|
|
241
|
+
if decoded.nil?
|
|
242
|
+
# Nothing but add writes these, so this is belt and braces. Drop it
|
|
243
|
+
# so an unparseable member cannot wedge the sweeper.
|
|
244
|
+
swallow { @client.zrem(index_key, member) }
|
|
245
|
+
return false
|
|
246
|
+
end
|
|
247
|
+
company_id, credit_type_id, id = decoded
|
|
248
|
+
refunded = consume(id, 0)
|
|
249
|
+
# Always drop the member just read. On the success path consume
|
|
250
|
+
# already removed it, so this is an idempotent no-op; it also covers
|
|
251
|
+
# the hash-evicted path below.
|
|
252
|
+
swallow { @client.zrem(index_key, member) }
|
|
253
|
+
return true unless refunded.nil?
|
|
254
|
+
|
|
255
|
+
# The claim found no reservation hash. Either a racing track already
|
|
256
|
+
# consumed it and reconciled the byCredit field, or the hash
|
|
257
|
+
# TTL-evicted before the sweeper reached it, orphaning that field.
|
|
258
|
+
# Reconcile it so reserved_credits cannot keep summing an evicted
|
|
259
|
+
# hold. The unspent slice is NOT refunded here: without the hash the
|
|
260
|
+
# claim cannot arbitrate exactly-once across racing sweepers, so the
|
|
261
|
+
# slice is reclaimed when the lease itself expires server-side.
|
|
262
|
+
swallow { @client.hdel(by_credit_key(company_id, credit_type_id), id) }
|
|
263
|
+
false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def hash_key(id)
|
|
267
|
+
"#{@key_prefix}#{RES_KEY_NAMESPACE}#{id}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def index_key
|
|
271
|
+
"#{@key_prefix}#{RES_INDEX_KEY}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def by_credit_key(company_id, credit_type_id)
|
|
275
|
+
"#{@key_prefix}#{RES_BYCREDIT_NAMESPACE}#{company_id}:#{credit_type_id}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def encode_member(company_id, credit_type_id, id)
|
|
279
|
+
[company_id, credit_type_id, id].join(MEMBER_DELIMITER)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def decode_member(member)
|
|
283
|
+
parts = member.to_s.split(MEMBER_DELIMITER)
|
|
284
|
+
return nil unless parts.size == 3
|
|
285
|
+
|
|
286
|
+
parts
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def millis(time)
|
|
290
|
+
(time.to_f * 1000).round
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def swallow
|
|
294
|
+
yield
|
|
295
|
+
rescue StandardError
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Decode a flat [field, value, field, value, ...] HGETALL array, which
|
|
300
|
+
# is what the claim script returns.
|
|
301
|
+
def decode_raw_array(raw)
|
|
302
|
+
return nil unless raw.is_a?(Array) && !raw.empty?
|
|
303
|
+
|
|
304
|
+
out = {}
|
|
305
|
+
raw.each_slice(2) { |field, value| out[field.to_s] = value.to_s if value }
|
|
306
|
+
out
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def decode_reservation(raw)
|
|
310
|
+
Reservation.new(
|
|
311
|
+
id: raw["id"],
|
|
312
|
+
lease_id: raw["leaseId"],
|
|
313
|
+
company_id: raw["companyId"],
|
|
314
|
+
credit_type_id: raw["creditTypeId"],
|
|
315
|
+
event_subtype: raw["eventSubtype"],
|
|
316
|
+
quantity_reserved: raw["quantityReserved"].to_f,
|
|
317
|
+
credits_reserved: raw["creditsReserved"].to_f,
|
|
318
|
+
consumption_rate: raw["consumptionRate"].to_f,
|
|
319
|
+
expires_at: Time.at(raw["expiresAt"].to_f / 1000.0),
|
|
320
|
+
eval_ctx: raw["evalCtx"] ? JSON.parse(raw["evalCtx"], symbolize_names: true) : {}
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schematic
|
|
4
|
+
module Credits
|
|
5
|
+
module Leases
|
|
6
|
+
# Per-process reservation table, paired with a sweep loop that returns
|
|
7
|
+
# expired reservations to their underlying lease.
|
|
8
|
+
#
|
|
9
|
+
# For cross-process deployments use RedisReservationStore instead: both
|
|
10
|
+
# answer the same calls with the same semantics.
|
|
11
|
+
class ReservationStore
|
|
12
|
+
def initialize(lease_store, sweep_interval_ms = DEFAULT_SWEEP_INTERVAL_MS, clock: DEFAULT_CLOCK,
|
|
13
|
+
logger: nil)
|
|
14
|
+
@lease_store = lease_store
|
|
15
|
+
@sweep_interval_ms = sweep_interval_ms
|
|
16
|
+
@clock = clock
|
|
17
|
+
@logger = logger
|
|
18
|
+
@reservations = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@sweep_thread = nil
|
|
21
|
+
@stopped = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Register a new reservation. Idempotent on id. It does NOT debit the
|
|
25
|
+
# lease: the debit already happened in try_reserve, and recording the
|
|
26
|
+
# hold after the debit is what bounds a crash between them to leaked
|
|
27
|
+
# credits rather than a double spend.
|
|
28
|
+
def add(reservation)
|
|
29
|
+
@mutex.synchronize { @reservations[reservation.id] = reservation }
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(id)
|
|
34
|
+
@mutex.synchronize { @reservations[id] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sum the open reservations for a (company, credit). A reservation
|
|
38
|
+
# counts while it is in the table: its credits stay carved out of the
|
|
39
|
+
# lease's local_remaining_credits until consume or sweep_expired removes
|
|
40
|
+
# it and refunds the unspent remainder in the same step, so
|
|
41
|
+
# local_remaining_credits + reserved_credits stays exact.
|
|
42
|
+
def reserved_credits(company_id, credit_type_id)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@reservations.each_value.sum do |reservation|
|
|
45
|
+
if reservation.company_id == company_id && reservation.credit_type_id == credit_type_id
|
|
46
|
+
reservation.credits_reserved
|
|
47
|
+
else
|
|
48
|
+
0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Claim a reservation exactly once and settle it: the clamped consumed
|
|
55
|
+
# slice stays debited and the unspent remainder is refunded to the
|
|
56
|
+
# lease. Returns the clamped figure, or nil when the reservation was
|
|
57
|
+
# already gone (swept at its TTL, or claimed by a racing caller).
|
|
58
|
+
#
|
|
59
|
+
# The claim is the arbiter. A crash between the claim and the refund
|
|
60
|
+
# leaks the unspent slice until the lease expires, which is the safe
|
|
61
|
+
# direction: a second claim finds nothing and refunds nothing.
|
|
62
|
+
def consume(id, credits_consumed)
|
|
63
|
+
reservation = @mutex.synchronize { @reservations.delete(id) }
|
|
64
|
+
return nil if reservation.nil?
|
|
65
|
+
|
|
66
|
+
actual = credits_consumed.clamp(0, reservation.credits_reserved)
|
|
67
|
+
refund = reservation.credits_reserved - actual
|
|
68
|
+
if refund.positive? && refundable?(reservation)
|
|
69
|
+
# Pinned to the originating lease: if that lease has expired and a
|
|
70
|
+
# successor holds the slot, the refund is dropped, because the
|
|
71
|
+
# expired lease's remainder was already returned server-side.
|
|
72
|
+
@lease_store.refund(reservation.company_id, reservation.credit_type_id, refund, reservation.lease_id)
|
|
73
|
+
end
|
|
74
|
+
actual
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Start the background sweep loop. Safe to call repeatedly.
|
|
78
|
+
def start_sweep
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
# alive?, not presence: a process that forks after start (Puma or
|
|
81
|
+
# Unicorn with preload) hands the child a thread object whose thread
|
|
82
|
+
# did not survive the fork, and the child would never sweep.
|
|
83
|
+
return if @sweep_thread&.alive? || @stopped
|
|
84
|
+
|
|
85
|
+
interval = @sweep_interval_ms.to_f / 1000.0
|
|
86
|
+
@sweep_thread = Thread.new do
|
|
87
|
+
loop do
|
|
88
|
+
sleep(interval)
|
|
89
|
+
break if @stopped
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
sweep_expired
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
# Keep the loop alive: a store blip must not stop the sweeper.
|
|
95
|
+
@logger&.debug("Reservation sweep failed: #{e.message}")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
@sweep_thread.abort_on_exception = false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Remove every reservation past its TTL and refund its full hold to its
|
|
104
|
+
# lease. Returns the number swept.
|
|
105
|
+
def sweep_expired(now = nil)
|
|
106
|
+
now ||= @clock.call
|
|
107
|
+
expired = @mutex.synchronize do
|
|
108
|
+
due = @reservations.each_value.select { |r| r.expires_at.to_f <= now.to_f }
|
|
109
|
+
due.each { |r| @reservations.delete(r.id) }
|
|
110
|
+
due
|
|
111
|
+
end
|
|
112
|
+
expired.each do |reservation|
|
|
113
|
+
next unless refundable?(reservation)
|
|
114
|
+
|
|
115
|
+
@lease_store.refund(
|
|
116
|
+
reservation.company_id,
|
|
117
|
+
reservation.credit_type_id,
|
|
118
|
+
reservation.credits_reserved,
|
|
119
|
+
reservation.lease_id
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
expired.size
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def stop
|
|
126
|
+
thread = @mutex.synchronize do
|
|
127
|
+
@stopped = true
|
|
128
|
+
thread = @sweep_thread
|
|
129
|
+
@sweep_thread = nil
|
|
130
|
+
thread
|
|
131
|
+
end
|
|
132
|
+
return nil if thread.nil?
|
|
133
|
+
|
|
134
|
+
# A sweep past its claim has deleted the reservation but not yet
|
|
135
|
+
# refunded the lease, and killing it there strands the unspent slice
|
|
136
|
+
# until the lease expires. Give it a bounded moment to land that
|
|
137
|
+
# refund, then kill: the loop checks @stopped as soon as its sleep
|
|
138
|
+
# ends, so a parked sweeper costs at most this wait.
|
|
139
|
+
thread.kill unless thread.join(SWEEP_STOP_JOIN_MS / 1000.0)
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def size
|
|
144
|
+
@mutex.synchronize { @reservations.size }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# A hold that cannot name the lease it came out of is not refundable:
|
|
150
|
+
# crediting whichever lease holds the slot now could inflate a successor
|
|
151
|
+
# whose grant the server issued whole, and the slice comes back when the
|
|
152
|
+
# lease expires anyway. Decided here rather than left to the lease
|
|
153
|
+
# store, because the two lease stores read a missing pin differently,
|
|
154
|
+
# and a fleet mixing SDKs on one Redis must agree.
|
|
155
|
+
def refundable?(reservation)
|
|
156
|
+
!reservation.lease_id.nil? && !reservation.lease_id.to_s.empty?
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|