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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.fern/metadata.json +3 -3
  3. data/.fern/replay.lock +8 -338
  4. data/.fernignore +6 -0
  5. data/README.md +141 -0
  6. data/custom.gemspec.rb +6 -0
  7. data/lib/schematic/accounts/types/update_environment_request_body.rb +2 -0
  8. data/lib/schematic/client.rb +1 -1
  9. data/lib/schematic/credits/client.rb +50 -6
  10. data/lib/schematic/credits/leases/check.rb +477 -0
  11. data/lib/schematic/credits/leases/lease_manager.rb +565 -0
  12. data/lib/schematic/credits/leases/lease_store.rb +240 -0
  13. data/lib/schematic/credits/leases/redis_lease_store.rb +358 -0
  14. data/lib/schematic/credits/leases/redis_reservation_store.rb +326 -0
  15. data/lib/schematic/credits/leases/reservation_store.rb +161 -0
  16. data/lib/schematic/credits/leases/server_check.rb +237 -0
  17. data/lib/schematic/credits/leases/track.rb +66 -0
  18. data/lib/schematic/credits/leases/types.rb +329 -0
  19. data/lib/schematic/credits/leases/wire_client.rb +83 -0
  20. data/lib/schematic/credits/types/acquire_credit_lease_request_body.rb +2 -0
  21. data/lib/schematic/credits/types/create_credit_spend_policy_request_body.rb +5 -1
  22. data/lib/schematic/credits/types/extend_credit_lease_request_body.rb +2 -0
  23. data/lib/schematic/credits/types/get_credit_spend_policy_usage_params.rb +16 -0
  24. data/lib/schematic/credits/types/get_credit_spend_policy_usage_request.rb +15 -0
  25. data/lib/schematic/credits/types/get_credit_spend_policy_usage_response.rb +13 -0
  26. data/lib/schematic/credits/types/update_credit_spend_policy_request_body.rb +4 -0
  27. data/lib/schematic/datastream/client.rb +32 -2
  28. data/lib/schematic/entitlements/client.rb +165 -0
  29. data/lib/schematic/entitlements/types/count_company_user_usage_params.rb +24 -0
  30. data/lib/schematic/entitlements/types/count_company_user_usage_request.rb +23 -0
  31. data/lib/schematic/entitlements/types/count_company_user_usage_response.rb +13 -0
  32. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_params.rb +16 -0
  33. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_request.rb +15 -0
  34. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_response.rb +13 -0
  35. data/lib/schematic/entitlements/types/list_company_user_usage_params.rb +24 -0
  36. data/lib/schematic/entitlements/types/list_company_user_usage_request.rb +23 -0
  37. data/lib/schematic/entitlements/types/list_company_user_usage_response.rb +13 -0
  38. data/lib/schematic/plangroups/client.rb +2 -0
  39. data/lib/schematic/plangroups/types/create_plan_group_request_body.rb +2 -0
  40. data/lib/schematic/plangroups/types/update_plan_group_request_body.rb +2 -0
  41. data/lib/schematic/planmigrations/client.rb +6 -0
  42. data/lib/schematic/planmigrations/types/count_migrations_params.rb +2 -0
  43. data/lib/schematic/planmigrations/types/count_migrations_request.rb +2 -0
  44. data/lib/schematic/planmigrations/types/create_migration_input.rb +2 -0
  45. data/lib/schematic/planmigrations/types/list_migrations_params.rb +2 -0
  46. data/lib/schematic/planmigrations/types/list_migrations_request.rb +2 -0
  47. data/lib/schematic/plans/types/publish_plan_version_request_body.rb +4 -0
  48. data/lib/schematic/plans/types/retry_custom_plan_billing_request_body.rb +2 -0
  49. data/lib/schematic/rules_engine.rb +37 -0
  50. data/lib/schematic/schematic_client.rb +830 -33
  51. data/lib/schematic/types/capture_raw_event.rb +4 -0
  52. data/lib/schematic/types/check_flags_response_data.rb +2 -0
  53. data/lib/schematic/types/company_detail_response_data.rb +2 -0
  54. data/lib/schematic/types/company_plan_detail_response_data.rb +2 -0
  55. data/lib/schematic/types/company_user_usage_metrics_response_data.rb +15 -0
  56. data/lib/schematic/types/company_user_usage_response_data.rb +19 -0
  57. data/lib/schematic/types/company_user_usage_row_response_data.rb +17 -0
  58. data/lib/schematic/types/component_display_settings.rb +2 -0
  59. data/lib/schematic/types/component_settings_response_data.rb +2 -0
  60. data/lib/schematic/types/credit_event_ledger_response_data.rb +5 -1
  61. data/lib/schematic/types/credit_event_type.rb +3 -0
  62. data/lib/schematic/types/credit_ledger_entry_kind.rb +17 -0
  63. data/lib/schematic/types/credit_spend_policy.rb +25 -0
  64. data/lib/schematic/types/credit_spend_policy_response_data.rb +12 -0
  65. data/lib/schematic/types/credit_spend_window.rb +11 -0
  66. data/lib/schematic/types/credit_spend_window_unit.rb +13 -0
  67. data/lib/schematic/types/custom_plan_billing_response_data.rb +2 -0
  68. data/lib/schematic/types/environment_detail_response_data.rb +2 -0
  69. data/lib/schematic/types/environment_response_data.rb +2 -0
  70. data/lib/schematic/types/estimated_plan_total.rb +13 -0
  71. data/lib/schematic/types/pending_migration_response_data.rb +6 -0
  72. data/lib/schematic/types/plan_version_migration_response_data.rb +2 -0
  73. data/lib/schematic/types/plan_version_migration_strategy.rb +1 -0
  74. data/lib/schematic/types/rules_engine_schema_version.rb +1 -1
  75. data/lib/schematic/types/rulesengine_company.rb +2 -0
  76. data/lib/schematic/types/rulesengine_credit_spend_policy.rb +25 -0
  77. data/lib/schematic/types/rulesengine_credit_spend_policy_scope.rb +13 -0
  78. data/lib/schematic/types/rulesengine_credit_spend_window.rb +11 -0
  79. data/lib/schematic/types/rulesengine_user.rb +2 -0
  80. data/lib/schematic/types/user_usage_metric.rb +12 -0
  81. data/lib/schematic/version.rb +1 -1
  82. data/lib/schematic.rb +26 -2
  83. data/lib/schematichq.rb +10 -0
  84. data/reference.md +474 -9
  85. 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