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,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schematic
|
|
4
|
+
module Credits
|
|
5
|
+
module Leases
|
|
6
|
+
def self.lease_key(company_id, credit_type_id)
|
|
7
|
+
"#{company_id}:#{credit_type_id}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Per-process lease store, keyed by "<company_id>:<credit_type_id>".
|
|
11
|
+
#
|
|
12
|
+
# Holds the lease id, the server-authoritative granted total, the expiry,
|
|
13
|
+
# and the SDK's local view of local_remaining_credits: the portion of the
|
|
14
|
+
# lease not yet carved out by an open reservation. A per-key mutex keeps
|
|
15
|
+
# reserve, refund, extend, and replace atomic per slot.
|
|
16
|
+
#
|
|
17
|
+
# The store never talks to the API. LeaseManager drives acquire, extend,
|
|
18
|
+
# and release over the wire and uses these methods to mirror remote state.
|
|
19
|
+
#
|
|
20
|
+
# For cross-process deployments use RedisLeaseStore instead: both answer
|
|
21
|
+
# the same calls with the same semantics.
|
|
22
|
+
class LeaseStore
|
|
23
|
+
def initialize(clock: DEFAULT_CLOCK)
|
|
24
|
+
@clock = clock
|
|
25
|
+
@leases = {}
|
|
26
|
+
@locks = {}
|
|
27
|
+
# Guards the two hashes themselves. Always taken inside a key lock,
|
|
28
|
+
# never the other way round, so the pair cannot deadlock.
|
|
29
|
+
@table_mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Snapshot of the current entry, or nil when no lease occupies the slot.
|
|
33
|
+
# A copy, so a caller cannot mutate store state by holding the result.
|
|
34
|
+
def get(company_id, credit_type_id)
|
|
35
|
+
key = Leases.lease_key(company_id, credit_type_id)
|
|
36
|
+
with_lock_if_present(key) { read(key)&.dup }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Install a fresh lease for the slot, but only if no live lease already
|
|
40
|
+
# occupies it.
|
|
41
|
+
#
|
|
42
|
+
# A live lease is left untouched even when its lease_id differs (a
|
|
43
|
+
# sibling process won the race), so its already-debited
|
|
44
|
+
# local_remaining_credits wins. An expired row carrying the SAME
|
|
45
|
+
# lease_id is not rewritten either: rewriting would reset
|
|
46
|
+
# local_remaining_credits to the full grant and erase debits whose
|
|
47
|
+
# reservations are still open, and the idempotent server hands a racing
|
|
48
|
+
# acquire that same active lease back. Such a row is reconciled like an
|
|
49
|
+
# extend instead: granted to the incoming total, expiry only forward,
|
|
50
|
+
# balance untouched.
|
|
51
|
+
#
|
|
52
|
+
# Returns true when a fresh row was written, false when an existing
|
|
53
|
+
# lease was kept or reconciled.
|
|
54
|
+
def replace(entry)
|
|
55
|
+
key = Leases.lease_key(entry.company_id, entry.credit_type_id)
|
|
56
|
+
with_lock(key) do
|
|
57
|
+
existing = read(key)
|
|
58
|
+
if existing && !existing.expired?(@clock.call)
|
|
59
|
+
false
|
|
60
|
+
elsif existing && existing.lease_id == entry.lease_id
|
|
61
|
+
reconcile(existing, entry.granted_amount, entry.expires_at)
|
|
62
|
+
false
|
|
63
|
+
else
|
|
64
|
+
write(key, LeaseEntry.new(
|
|
65
|
+
lease_id: entry.lease_id,
|
|
66
|
+
company_id: entry.company_id,
|
|
67
|
+
credit_type_id: entry.credit_type_id,
|
|
68
|
+
granted_amount: entry.granted_amount,
|
|
69
|
+
expires_at: entry.expires_at
|
|
70
|
+
))
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Reconcile the slot to the server-authoritative granted total after a
|
|
77
|
+
# remote extend, crediting the difference to local_remaining_credits.
|
|
78
|
+
#
|
|
79
|
+
# The delta is computed here against the CURRENT stored total, never by
|
|
80
|
+
# the caller from a pre-wire-call read: two writers extending the same
|
|
81
|
+
# lease concurrently would each apply a delta against the same stale
|
|
82
|
+
# read and mint phantom credits. Reconciling to the absolute total
|
|
83
|
+
# converges in any order, and the expiry only ever moves forward so an
|
|
84
|
+
# out-of-order apply cannot shorten a lease a sibling just extended.
|
|
85
|
+
#
|
|
86
|
+
# When pin_lease_id is given the extend applies only if the slot still
|
|
87
|
+
# holds that lease: the server extended lease A, so its credits must not
|
|
88
|
+
# land on a successor B that replaced A after it expired mid-extend.
|
|
89
|
+
def extend(company_id, credit_type_id, granted_amount, new_expires_at = nil, pin_lease_id = nil)
|
|
90
|
+
key = Leases.lease_key(company_id, credit_type_id)
|
|
91
|
+
with_lock_if_present(key) do
|
|
92
|
+
entry = read(key)
|
|
93
|
+
next if entry.nil?
|
|
94
|
+
next if pin_lease_id && entry.lease_id != pin_lease_id
|
|
95
|
+
|
|
96
|
+
reconcile(entry, granted_amount, new_expires_at)
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Drop the slot entry, after a remote release. An explicit drop is the
|
|
102
|
+
# only thing that removes one: a lease that merely expired stays here,
|
|
103
|
+
# readable, until it is dropped or replaced, which is what the spec
|
|
104
|
+
# requires, so every path re-guards on expiry rather than trusting
|
|
105
|
+
# presence. The slot's mutex goes with the entry, so a long-lived
|
|
106
|
+
# process does not accumulate one per slot it has ever leased.
|
|
107
|
+
def drop(company_id, credit_type_id)
|
|
108
|
+
key = Leases.lease_key(company_id, credit_type_id)
|
|
109
|
+
with_lock(key) do
|
|
110
|
+
@table_mutex.synchronize do
|
|
111
|
+
@leases.delete(key)
|
|
112
|
+
@locks.delete(key)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Atomically check and debit `credits` from the lease's remaining
|
|
119
|
+
# balance. Returns a ReserveResult carrying the post-debit balance and
|
|
120
|
+
# the lease the debit landed on, or nil when there is no live lease, the
|
|
121
|
+
# balance is short, or `credits` is not a finite non-negative number.
|
|
122
|
+
#
|
|
123
|
+
# The debit is not keyed by lease id: it charges whichever lease holds
|
|
124
|
+
# the slot at that moment, which need not be the one the caller's
|
|
125
|
+
# acquire returned. The caller must therefore pin its reservation to the
|
|
126
|
+
# returned lease_id, since the settle refund, the sweep refund, and the
|
|
127
|
+
# track event's lease_id all have to name the lease that was charged.
|
|
128
|
+
def try_reserve(company_id, credit_type_id, credits)
|
|
129
|
+
# NaN passes every comparison below, and a NaN balance would approve
|
|
130
|
+
# every later reserve, so reject it before it reaches the arithmetic.
|
|
131
|
+
return nil unless Leases.valid_quantity?(credits)
|
|
132
|
+
|
|
133
|
+
key = Leases.lease_key(company_id, credit_type_id)
|
|
134
|
+
with_lock_if_present(key) do
|
|
135
|
+
entry = read(key)
|
|
136
|
+
next nil if entry.nil?
|
|
137
|
+
# An expired lease is released server-side and its grant refunded to
|
|
138
|
+
# the company balance, so its local balance is stale.
|
|
139
|
+
next nil if entry.expired?(@clock.call)
|
|
140
|
+
next nil if entry.local_remaining_credits < credits
|
|
141
|
+
|
|
142
|
+
entry.local_remaining_credits -= credits
|
|
143
|
+
# The lease id is read under the same lock as the debit: a read
|
|
144
|
+
# afterwards could name a lease that replaced this one in between.
|
|
145
|
+
ReserveResult.new(entry.local_remaining_credits, entry.lease_id)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Refund credits to the slot's lease balance, clamped at granted_amount.
|
|
150
|
+
#
|
|
151
|
+
# When pin_lease_id is given the refund applies only if the slot still
|
|
152
|
+
# holds that lease: a hold carved out of expired lease A must never
|
|
153
|
+
# inflate a successor B, because A's unspent remainder was already
|
|
154
|
+
# returned to the company balance server-side when A expired.
|
|
155
|
+
def refund(company_id, credit_type_id, credits, pin_lease_id = nil)
|
|
156
|
+
return nil if credits.nil? || credits <= 0
|
|
157
|
+
|
|
158
|
+
key = Leases.lease_key(company_id, credit_type_id)
|
|
159
|
+
with_lock_if_present(key) do
|
|
160
|
+
entry = read(key)
|
|
161
|
+
next if entry.nil?
|
|
162
|
+
next if pin_lease_id && entry.lease_id != pin_lease_id
|
|
163
|
+
|
|
164
|
+
entry.local_remaining_credits = [entry.local_remaining_credits + credits, entry.granted_amount].min
|
|
165
|
+
end
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Snapshot of every entry. Only the per-process store implements this:
|
|
170
|
+
# close releases the leases this process exclusively holds, and a shared
|
|
171
|
+
# backend must never enumerate, since sibling processes may still be
|
|
172
|
+
# drawing on those leases.
|
|
173
|
+
def list
|
|
174
|
+
keys = @table_mutex.synchronize { @leases.keys }
|
|
175
|
+
keys.filter_map { |key| with_lock(key) { read(key)&.dup } }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
# Granted to the incoming total, expiry only forward, balance untouched
|
|
181
|
+
# except for the credited difference. Shared by replace's same-lease
|
|
182
|
+
# path and extend, which reconcile identically.
|
|
183
|
+
def reconcile(entry, granted_amount, new_expires_at)
|
|
184
|
+
add = granted_amount.to_f - entry.granted_amount
|
|
185
|
+
if add.positive?
|
|
186
|
+
entry.granted_amount = granted_amount.to_f
|
|
187
|
+
entry.local_remaining_credits += add
|
|
188
|
+
end
|
|
189
|
+
return unless new_expires_at && new_expires_at.to_f > entry.expires_at.to_f
|
|
190
|
+
|
|
191
|
+
entry.expires_at = new_expires_at
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def read(key)
|
|
195
|
+
@table_mutex.synchronize { @leases[key] }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def write(key, entry)
|
|
199
|
+
@table_mutex.synchronize { @leases[key] = entry }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A read of a slot nothing has leased must not leave a mutex behind, or
|
|
203
|
+
# pruning would only half work: a process checking flags for companies
|
|
204
|
+
# that never lease would still collect one per slot it asked about. With
|
|
205
|
+
# no lock registered and no lease stored there is nothing to serialize
|
|
206
|
+
# against, so answer without taking one. A writer landing in that window
|
|
207
|
+
# is the same race as reading a moment earlier.
|
|
208
|
+
def with_lock_if_present(key, &)
|
|
209
|
+
present = @table_mutex.synchronize { @locks.key?(key) || @leases.key?(key) }
|
|
210
|
+
return nil unless present
|
|
211
|
+
|
|
212
|
+
with_lock(key, &)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Serialize on the slot's mutex, re-checking after the acquire that it
|
|
216
|
+
# is still the registered one.
|
|
217
|
+
#
|
|
218
|
+
# Pruning is what makes the re-check necessary. A drop deletes the mutex
|
|
219
|
+
# while holding it, so a thread that was already blocked on it wakes
|
|
220
|
+
# owning an object the table no longer knows about, while a thread
|
|
221
|
+
# arriving afterwards takes a fresh mutex for the same slot. Without the
|
|
222
|
+
# check those two would run side by side on one slot. The waiter sees
|
|
223
|
+
# its mutex is no longer the registered one and retries against the
|
|
224
|
+
# current one instead. Every retry follows a drop, which happens once
|
|
225
|
+
# per lease, so the loop cannot spin.
|
|
226
|
+
def with_lock(key, &block)
|
|
227
|
+
loop do
|
|
228
|
+
lock = @table_mutex.synchronize { @locks[key] ||= Mutex.new }
|
|
229
|
+
stale = false
|
|
230
|
+
result = lock.synchronize do
|
|
231
|
+
stale = @table_mutex.synchronize { !@locks[key].equal?(lock) }
|
|
232
|
+
block.call unless stale
|
|
233
|
+
end
|
|
234
|
+
return result unless stale
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Schematic
|
|
6
|
+
module Credits
|
|
7
|
+
module Leases
|
|
8
|
+
# Minimal interface describing the Redis client methods the lease and
|
|
9
|
+
# reservation stores use. Compatible with the "redis" gem's client, which
|
|
10
|
+
# is deliberately not a dependency: the caller injects a client it already
|
|
11
|
+
# has, the same way RedisCacheProvider takes one.
|
|
12
|
+
module RedisLeaseClientInterface
|
|
13
|
+
def evalsha(sha, keys:, argv:)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def eval(script, keys:, argv:)
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def hgetall(key)
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Called as hset(key, field1, value1, field2, value2, ...).
|
|
26
|
+
def hset(key, *pairs)
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def hdel(key, field)
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def del(*keys)
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pexpireat(key, millis)
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def zadd(key, score, member)
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def zrem(key, member)
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def zrangebyscore(key, min, max, limit: nil)
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def zcard(key)
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A Lua script addressed by its SHA. EVALSHA first so the script body is
|
|
60
|
+
# not resent on every call; a Redis that has never seen it (a restart, or
|
|
61
|
+
# a node this process has not talked to) answers NOSCRIPT and the full
|
|
62
|
+
# body goes out once to load it.
|
|
63
|
+
class Script
|
|
64
|
+
attr_reader :source, :sha
|
|
65
|
+
|
|
66
|
+
def initialize(source)
|
|
67
|
+
@source = source
|
|
68
|
+
@sha = Digest::SHA1.hexdigest(source)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def call(client, keys:, argv:)
|
|
72
|
+
client.evalsha(@sha, keys: keys, argv: argv)
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
raise unless e.message.to_s.include?("NOSCRIPT")
|
|
75
|
+
|
|
76
|
+
client.eval(@source, keys: keys, argv: argv)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Redis-backed lease store. One hash per (company_id, credit_type_id)
|
|
81
|
+
# slot, mutated by single-key Lua scripts so the store stays correct on
|
|
82
|
+
# standalone and clustered Redis alike.
|
|
83
|
+
#
|
|
84
|
+
# The scripts below are byte-identical to the ones the Node, Go, and
|
|
85
|
+
# Python SDKs ship, and the key layout matches, so a mixed-language fleet
|
|
86
|
+
# shares one lease per slot. Do not re-derive them.
|
|
87
|
+
class RedisLeaseStore
|
|
88
|
+
DEFAULT_KEY_PREFIX = "schematic:"
|
|
89
|
+
LEASE_KEY_NAMESPACE = "credit-lease:"
|
|
90
|
+
# How long after the declared expiry the Redis row is kept before
|
|
91
|
+
# auto-eviction. Gives the sweeper a window to refund expired
|
|
92
|
+
# reservations before the underlying lease state disappears.
|
|
93
|
+
LEASE_TTL_GRACE_MS = 60_000
|
|
94
|
+
|
|
95
|
+
# Every script below touches exactly ONE key (the lease hash), which
|
|
96
|
+
# keeps them safe under Redis Cluster, where a multi-key script spanning
|
|
97
|
+
# slots raises CROSSSLOT. Only the lease hash needs atomic mutation;
|
|
98
|
+
# cross-key bookkeeping uses ordinary single-key commands.
|
|
99
|
+
#
|
|
100
|
+
# Expiry is decided against the Redis server's clock (redis.call('TIME')),
|
|
101
|
+
# not the calling process's: with many processes sharing one lease, local
|
|
102
|
+
# clock skew would let them disagree on whether the lease is live.
|
|
103
|
+
|
|
104
|
+
# Atomic replace. Writes the lease hash only when the slot is empty or
|
|
105
|
+
# the existing lease has expired. Returns 1 on write, 0 when a LIVE
|
|
106
|
+
# lease already occupies the slot, even one with a different leaseId
|
|
107
|
+
# installed by a sibling that raced this acquire. An expired row with
|
|
108
|
+
# the SAME leaseId is reconciled like an extend instead of rewritten,
|
|
109
|
+
# since rewriting would reset the balance and erase debits whose
|
|
110
|
+
# reservations are still open.
|
|
111
|
+
REPLACE_SCRIPT = Script.new(<<~LUA)
|
|
112
|
+
|
|
113
|
+
redis.replicate_commands()
|
|
114
|
+
local t = redis.call('TIME')
|
|
115
|
+
local now = (tonumber(t[1]) * 1000) + math.floor(tonumber(t[2]) / 1000)
|
|
116
|
+
|
|
117
|
+
local existing_id = redis.call('HGET', KEYS[1], 'leaseId')
|
|
118
|
+
local existing_expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0')
|
|
119
|
+
local new_id = ARGV[1]
|
|
120
|
+
local new_granted = ARGV[2]
|
|
121
|
+
local new_expiry = tonumber(ARGV[3])
|
|
122
|
+
local grace = tonumber(ARGV[4])
|
|
123
|
+
|
|
124
|
+
if existing_id and existing_expiry > now then
|
|
125
|
+
return 0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if existing_id == new_id then
|
|
129
|
+
local granted = tonumber(redis.call('HGET', KEYS[1], 'grantedAmount') or '0')
|
|
130
|
+
local add = tonumber(new_granted) - granted
|
|
131
|
+
if add > 0 then
|
|
132
|
+
local remaining = tonumber(redis.call('HGET', KEYS[1], 'localRemainingCredits') or '0')
|
|
133
|
+
redis.call('HSET', KEYS[1],
|
|
134
|
+
'grantedAmount', new_granted,
|
|
135
|
+
'localRemainingCredits', tostring(remaining + add))
|
|
136
|
+
end
|
|
137
|
+
if new_expiry > existing_expiry then
|
|
138
|
+
redis.call('HSET', KEYS[1], 'expiresAt', ARGV[3])
|
|
139
|
+
redis.call('PEXPIREAT', KEYS[1], new_expiry + grace)
|
|
140
|
+
end
|
|
141
|
+
return 0
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
redis.call('DEL', KEYS[1])
|
|
145
|
+
redis.call('HSET', KEYS[1],
|
|
146
|
+
'leaseId', new_id,
|
|
147
|
+
'companyId', ARGV[5],
|
|
148
|
+
'creditTypeId', ARGV[6],
|
|
149
|
+
'grantedAmount', new_granted,
|
|
150
|
+
'localRemainingCredits', new_granted,
|
|
151
|
+
'expiresAt', ARGV[3])
|
|
152
|
+
redis.call('PEXPIREAT', KEYS[1], new_expiry + grace)
|
|
153
|
+
return 1
|
|
154
|
+
LUA
|
|
155
|
+
|
|
156
|
+
# Atomic check and decrement on localRemainingCredits. Returns
|
|
157
|
+
# [post-debit balance, charged leaseId] on success, with the balance as
|
|
158
|
+
# a string because a Lua number reply truncates to integer and would
|
|
159
|
+
# corrupt fractional credit costs; a nil reply when there is no lease,
|
|
160
|
+
# the lease has expired, or the remaining balance is short. Returning
|
|
161
|
+
# the lease id read inside the same script is what lets the caller pin
|
|
162
|
+
# its reservation to the lease the debit actually landed on.
|
|
163
|
+
TRY_RESERVE_SCRIPT = Script.new(<<~LUA)
|
|
164
|
+
|
|
165
|
+
redis.replicate_commands()
|
|
166
|
+
local t = redis.call('TIME')
|
|
167
|
+
local now = (tonumber(t[1]) * 1000) + math.floor(tonumber(t[2]) / 1000)
|
|
168
|
+
|
|
169
|
+
local raw = redis.call('HGET', KEYS[1], 'localRemainingCredits')
|
|
170
|
+
if not raw then return false end
|
|
171
|
+
local lease_id = redis.call('HGET', KEYS[1], 'leaseId')
|
|
172
|
+
if not lease_id then return false end
|
|
173
|
+
local expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0')
|
|
174
|
+
if expiry <= now then return false end
|
|
175
|
+
local remaining = tonumber(raw)
|
|
176
|
+
local requested = tonumber(ARGV[1])
|
|
177
|
+
if remaining < requested then return false end
|
|
178
|
+
local new_remaining = remaining - requested
|
|
179
|
+
redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(new_remaining))
|
|
180
|
+
return { tostring(new_remaining), lease_id }
|
|
181
|
+
LUA
|
|
182
|
+
|
|
183
|
+
# Refund credits, clamped at grantedAmount. ARGV[2], when non-empty,
|
|
184
|
+
# pins the refund to a leaseId: if the slot now holds a different lease,
|
|
185
|
+
# the refund is dropped, because the expired lease's unspent remainder
|
|
186
|
+
# was already returned to the company balance server-side.
|
|
187
|
+
REFUND_SCRIPT = Script.new(<<~LUA)
|
|
188
|
+
|
|
189
|
+
local raw_remaining = redis.call('HGET', KEYS[1], 'localRemainingCredits')
|
|
190
|
+
if not raw_remaining then return 0 end
|
|
191
|
+
local required_lease = ARGV[2]
|
|
192
|
+
if required_lease and required_lease ~= '' then
|
|
193
|
+
local current_lease = redis.call('HGET', KEYS[1], 'leaseId')
|
|
194
|
+
if current_lease ~= required_lease then return 0 end
|
|
195
|
+
end
|
|
196
|
+
local remaining = tonumber(raw_remaining)
|
|
197
|
+
local granted = tonumber(redis.call('HGET', KEYS[1], 'grantedAmount') or '0')
|
|
198
|
+
local refund = tonumber(ARGV[1])
|
|
199
|
+
local new_balance = remaining + refund
|
|
200
|
+
if new_balance > granted then new_balance = granted end
|
|
201
|
+
redis.call('HSET', KEYS[1], 'localRemainingCredits', tostring(new_balance))
|
|
202
|
+
return 1
|
|
203
|
+
LUA
|
|
204
|
+
|
|
205
|
+
# Reconcile the lease to the server-authoritative grantedAmount total,
|
|
206
|
+
# crediting the difference to localRemainingCredits. The delta is
|
|
207
|
+
# computed HERE, atomically against the hash's current total, so two
|
|
208
|
+
# processes extending the same lease concurrently converge instead of
|
|
209
|
+
# minting phantom credits. Expiry only moves forward. ARGV[4] pins the
|
|
210
|
+
# extend to a leaseId, mirroring the pin on the refund.
|
|
211
|
+
EXTEND_SCRIPT = Script.new(<<~LUA)
|
|
212
|
+
|
|
213
|
+
local raw_granted = redis.call('HGET', KEYS[1], 'grantedAmount')
|
|
214
|
+
if not raw_granted then return 0 end
|
|
215
|
+
local required_lease = ARGV[4]
|
|
216
|
+
if required_lease and required_lease ~= '' then
|
|
217
|
+
local current_lease = redis.call('HGET', KEYS[1], 'leaseId')
|
|
218
|
+
if current_lease ~= required_lease then return 0 end
|
|
219
|
+
end
|
|
220
|
+
local granted = tonumber(raw_granted)
|
|
221
|
+
local target = tonumber(ARGV[1])
|
|
222
|
+
local add = target - granted
|
|
223
|
+
if add > 0 then
|
|
224
|
+
local remaining = tonumber(redis.call('HGET', KEYS[1], 'localRemainingCredits') or '0')
|
|
225
|
+
redis.call('HSET', KEYS[1],
|
|
226
|
+
'grantedAmount', tostring(target),
|
|
227
|
+
'localRemainingCredits', tostring(remaining + add))
|
|
228
|
+
end
|
|
229
|
+
local new_expiry = tonumber(ARGV[2])
|
|
230
|
+
local grace = tonumber(ARGV[3])
|
|
231
|
+
local current_expiry = tonumber(redis.call('HGET', KEYS[1], 'expiresAt') or '0')
|
|
232
|
+
if new_expiry > current_expiry then
|
|
233
|
+
redis.call('HSET', KEYS[1], 'expiresAt', ARGV[2])
|
|
234
|
+
redis.call('PEXPIREAT', KEYS[1], new_expiry + grace)
|
|
235
|
+
end
|
|
236
|
+
return 1
|
|
237
|
+
LUA
|
|
238
|
+
|
|
239
|
+
def initialize(client:, key_prefix: nil, default_lease_duration_ms: DEFAULT_LEASE_DURATION_MS,
|
|
240
|
+
clock: DEFAULT_CLOCK)
|
|
241
|
+
@client = client
|
|
242
|
+
@key_prefix = key_prefix || DEFAULT_KEY_PREFIX
|
|
243
|
+
# Defensive fallback for a direct caller that extends without naming a
|
|
244
|
+
# new expiry. The lease manager always passes one.
|
|
245
|
+
@default_lease_duration_ms = default_lease_duration_ms
|
|
246
|
+
@clock = clock
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Public so the reservation store can target the same lease hash.
|
|
250
|
+
def hash_key(company_id, credit_type_id)
|
|
251
|
+
"#{@key_prefix}#{LEASE_KEY_NAMESPACE}#{Leases.lease_key(company_id, credit_type_id)}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def get(company_id, credit_type_id)
|
|
255
|
+
raw = @client.hgetall(hash_key(company_id, credit_type_id))
|
|
256
|
+
return nil if raw.nil? || raw["leaseId"].nil?
|
|
257
|
+
|
|
258
|
+
decode_entry(raw)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# rubocop:disable Naming/PredicateMethod
|
|
262
|
+
# replace is the store method name every Schematic SDK shares; its
|
|
263
|
+
# boolean says whether a fresh row was written or an existing lease kept.
|
|
264
|
+
def replace(entry)
|
|
265
|
+
# No process clock here: the script reads now from the Redis server
|
|
266
|
+
# via TIME, so every process agrees on expiry.
|
|
267
|
+
result = REPLACE_SCRIPT.call(
|
|
268
|
+
@client,
|
|
269
|
+
keys: [hash_key(entry.company_id, entry.credit_type_id)],
|
|
270
|
+
argv: [
|
|
271
|
+
entry.lease_id,
|
|
272
|
+
num(entry.granted_amount),
|
|
273
|
+
millis(entry.expires_at).to_s,
|
|
274
|
+
LEASE_TTL_GRACE_MS.to_s,
|
|
275
|
+
entry.company_id,
|
|
276
|
+
entry.credit_type_id
|
|
277
|
+
]
|
|
278
|
+
)
|
|
279
|
+
result.to_i == 1
|
|
280
|
+
end
|
|
281
|
+
# rubocop:enable Naming/PredicateMethod
|
|
282
|
+
|
|
283
|
+
def extend(company_id, credit_type_id, granted_amount, new_expires_at = nil, pin_lease_id = nil)
|
|
284
|
+
expiry = new_expires_at ? millis(new_expires_at) : millis(@clock.call) + @default_lease_duration_ms
|
|
285
|
+
# granted_amount is the server-authoritative TOTAL; the script computes
|
|
286
|
+
# the credit delta atomically against the stored total. An empty string
|
|
287
|
+
# disables the lease pin, since Lua has no nil ARGV.
|
|
288
|
+
EXTEND_SCRIPT.call(
|
|
289
|
+
@client,
|
|
290
|
+
keys: [hash_key(company_id, credit_type_id)],
|
|
291
|
+
argv: [num(granted_amount), expiry.to_s, LEASE_TTL_GRACE_MS.to_s, pin_lease_id.to_s]
|
|
292
|
+
)
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def drop(company_id, credit_type_id)
|
|
297
|
+
# A plain single-key delete: there is no secondary index to keep in sync.
|
|
298
|
+
@client.del(hash_key(company_id, credit_type_id))
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def try_reserve(company_id, credit_type_id, credits)
|
|
303
|
+
# Reject non-finite or negative debits before they reach the script:
|
|
304
|
+
# NaN.to_s parses back to a Lua nan, slips through the comparison, and
|
|
305
|
+
# would poison the SHARED balance for every process.
|
|
306
|
+
return nil unless Leases.valid_quantity?(credits)
|
|
307
|
+
|
|
308
|
+
result = TRY_RESERVE_SCRIPT.call(
|
|
309
|
+
@client,
|
|
310
|
+
keys: [hash_key(company_id, credit_type_id)],
|
|
311
|
+
# Only the requested amount: now comes from the Redis server clock.
|
|
312
|
+
argv: [num(credits)]
|
|
313
|
+
)
|
|
314
|
+
return nil if result.nil? || result == false
|
|
315
|
+
|
|
316
|
+
ReserveResult.new(result[0].to_f, result[1].to_s)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def refund(company_id, credit_type_id, credits, pin_lease_id = nil)
|
|
320
|
+
return nil if credits.nil? || credits <= 0
|
|
321
|
+
|
|
322
|
+
REFUND_SCRIPT.call(
|
|
323
|
+
@client,
|
|
324
|
+
keys: [hash_key(company_id, credit_type_id)],
|
|
325
|
+
# An empty string disables the lease pin, since Lua has no nil ARGV.
|
|
326
|
+
argv: [num(credits), pin_lease_id.to_s]
|
|
327
|
+
)
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
private
|
|
332
|
+
|
|
333
|
+
# Lua's tonumber reads a decimal string, and Ruby's to_s does not always
|
|
334
|
+
# produce one: a Rational quantity renders as "1/2", which the script
|
|
335
|
+
# reads as nil and treats as a zero. Integers are already safe and stay
|
|
336
|
+
# exact, so only the rest is forced through Float.
|
|
337
|
+
def num(value)
|
|
338
|
+
value.is_a?(Integer) ? value.to_s : value.to_f.to_s
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def millis(time)
|
|
342
|
+
(time.to_f * 1000).round
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def decode_entry(raw)
|
|
346
|
+
LeaseEntry.new(
|
|
347
|
+
lease_id: raw["leaseId"],
|
|
348
|
+
company_id: raw["companyId"],
|
|
349
|
+
credit_type_id: raw["creditTypeId"],
|
|
350
|
+
granted_amount: (raw["grantedAmount"] || 0).to_f,
|
|
351
|
+
local_remaining_credits: (raw["localRemainingCredits"] || 0).to_f,
|
|
352
|
+
expires_at: Time.at((raw["expiresAt"] || 0).to_f / 1000.0)
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|