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,565 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Schematic
|
|
6
|
+
module Credits
|
|
7
|
+
module Leases
|
|
8
|
+
# The monotonic-clock millisecond deadline a caller's timeout sets for
|
|
9
|
+
# waiting on a shared flight, or nil for no cap. Taken once, at check
|
|
10
|
+
# start, so every wait the check makes draws on the same budget.
|
|
11
|
+
def self.join_deadline(request_options)
|
|
12
|
+
seconds = request_options.is_a?(Hash) ? request_options[:timeout_in_seconds] : nil
|
|
13
|
+
return nil unless seconds.is_a?(Numeric) && seconds.to_f.finite?
|
|
14
|
+
|
|
15
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000) + (seconds * 1000)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One wire call in flight for a slot, plus the figure it asked for, which
|
|
19
|
+
# is what a joiner compares its own shortfall against. Threads that arrive
|
|
20
|
+
# while it runs wait on it instead of issuing a second call.
|
|
21
|
+
class Flight
|
|
22
|
+
attr_reader :requested_additional
|
|
23
|
+
|
|
24
|
+
def initialize(requested_additional = nil)
|
|
25
|
+
@requested_additional = requested_additional
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@condition = ConditionVariable.new
|
|
28
|
+
@done = false
|
|
29
|
+
@value = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def complete(value)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@value = value
|
|
35
|
+
@done = true
|
|
36
|
+
@condition.broadcast
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Wait for the flight to land. A timeout_ms of nil waits indefinitely;
|
|
41
|
+
# a shutdown passes its remaining budget so a stalled wire call cannot
|
|
42
|
+
# hold the close open.
|
|
43
|
+
def wait(timeout_ms = nil)
|
|
44
|
+
deadline = timeout_ms ? monotonic_ms + timeout_ms : nil
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
until @done
|
|
47
|
+
if deadline
|
|
48
|
+
remaining = deadline - monotonic_ms
|
|
49
|
+
break if remaining <= 0
|
|
50
|
+
|
|
51
|
+
@condition.wait(@mutex, remaining / 1000.0)
|
|
52
|
+
else
|
|
53
|
+
@condition.wait(@mutex)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
@value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def done?
|
|
61
|
+
@mutex.synchronize { @done }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def monotonic_ms
|
|
67
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Owns the lifecycle of credit leases for a single client: acquire on
|
|
72
|
+
# first use or after expiry, extend when the local view dips below the low
|
|
73
|
+
# water mark, release on close.
|
|
74
|
+
#
|
|
75
|
+
# Acquire and extend each get their own single-flight map, kept separate
|
|
76
|
+
# so an in-flight extend can never satisfy an acquire. Best-effort: a
|
|
77
|
+
# caller racing ahead of the registration can still duplicate a wire call,
|
|
78
|
+
# which is safe, because the server is idempotent for an active slot,
|
|
79
|
+
# replace keeps the first live lease, and extend reconciles to a total.
|
|
80
|
+
class LeaseManager
|
|
81
|
+
# A wait on a shared extend that ran out the joiner's own timeout.
|
|
82
|
+
JOIN_TIMED_OUT = Object.new.freeze
|
|
83
|
+
|
|
84
|
+
def initialize(wire_client:, lease_store:, logger:, config: {}, clock: DEFAULT_CLOCK)
|
|
85
|
+
@wire = wire_client
|
|
86
|
+
@lease_store = lease_store
|
|
87
|
+
@logger = logger
|
|
88
|
+
@config = config || {}
|
|
89
|
+
@clock = clock
|
|
90
|
+
@flight_mutex = Mutex.new
|
|
91
|
+
@inflight_acquire = {}
|
|
92
|
+
@inflight_extend = {}
|
|
93
|
+
# Lease work nobody joins: the redundant release a lost acquire race
|
|
94
|
+
# issues, and the background extends callers fire and forget. drain
|
|
95
|
+
# waits these out so a close releases what they installed.
|
|
96
|
+
@background = []
|
|
97
|
+
@stopped = false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resolve_config(credit_type_id)
|
|
101
|
+
Leases.resolve_config(@config, credit_type_id)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Return the slot's lease, acquiring one (or replacing an expired one)
|
|
105
|
+
# if none is live. Never raises: a wire or store failure is logged and
|
|
106
|
+
# reported as nil, so callers route it through their fail-open or
|
|
107
|
+
# fail-closed handling.
|
|
108
|
+
#
|
|
109
|
+
# deadline is a monotonic-clock millisecond cap on waiting for another
|
|
110
|
+
# caller's acquire, fixed once when the check started so the waits a
|
|
111
|
+
# check makes cannot each restart its timeout. Without one the cap is
|
|
112
|
+
# derived from request_options' timeout as of this call.
|
|
113
|
+
def acquire_if_needed(company_id, credit_type_id, request_options = nil, deadline: nil)
|
|
114
|
+
# Past stop the drain has run or is running, so a lease acquired now
|
|
115
|
+
# is one nothing is left to release.
|
|
116
|
+
return log_stopped("acquire", company_id, credit_type_id) if @stopped
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
existing = @lease_store.get(company_id, credit_type_id)
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
@logger.error("Failed to read lease store for #{company_id}/#{credit_type_id}: #{e.message}")
|
|
122
|
+
return nil
|
|
123
|
+
end
|
|
124
|
+
return existing if existing && !existing.expired?(@clock.call)
|
|
125
|
+
|
|
126
|
+
# An expired or absent slot is left for replace to overwrite, which
|
|
127
|
+
# guards on expiry and writes atomically. Dropping it first would be a
|
|
128
|
+
# separate op that can interleave between a sibling's read and its
|
|
129
|
+
# replace, clobbering the lease that sibling just installed.
|
|
130
|
+
|
|
131
|
+
# Check again: stop may have landed during the store read.
|
|
132
|
+
return log_stopped("acquire", company_id, credit_type_id) if @stopped
|
|
133
|
+
|
|
134
|
+
key = Leases.lease_key(company_id, credit_type_id)
|
|
135
|
+
flight, leader = enlist(@inflight_acquire, key) { Flight.new }
|
|
136
|
+
return log_stopped("acquire", company_id, credit_type_id) if flight.nil?
|
|
137
|
+
return join_acquire(flight, deadline || join_deadline(request_options), company_id, credit_type_id) unless leader
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
result = acquire(company_id, credit_type_id, request_options)
|
|
141
|
+
ensure
|
|
142
|
+
@flight_mutex.synchronize { @inflight_acquire.delete(key) }
|
|
143
|
+
flight.complete(result)
|
|
144
|
+
end
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Kick off an extend when one is due, on a background thread a caller can
|
|
149
|
+
# fire and forget. Returns the thread, or nil when nothing is due; the
|
|
150
|
+
# check flow joins it when the extend covers a reserve it just failed.
|
|
151
|
+
#
|
|
152
|
+
# Due means at or below the low-water-mark ratio, or below a named
|
|
153
|
+
# required_credits: one large check should not wait for the next
|
|
154
|
+
# sub-watermark check to top the lease up. deadline caps a wait on
|
|
155
|
+
# another caller's extend, as for acquire_if_needed. Never raises.
|
|
156
|
+
def maybe_extend_in_background(company_id, credit_type_id, required_credits = nil, request_options = nil,
|
|
157
|
+
deadline: nil)
|
|
158
|
+
if @stopped
|
|
159
|
+
# Extending past stop re-holds credits on a lease the close is about
|
|
160
|
+
# to release, or has already released.
|
|
161
|
+
log_stopped("extend", company_id, credit_type_id)
|
|
162
|
+
return nil
|
|
163
|
+
end
|
|
164
|
+
# Decided here rather than inside the thread: most checks sit nowhere
|
|
165
|
+
# near the water mark, and spawning one thread per check to learn that
|
|
166
|
+
# costs far more than the store read that answers it.
|
|
167
|
+
return nil unless extend_due?(company_id, credit_type_id, required_credits)
|
|
168
|
+
# An extend already on the wire is fetching the credits a watermark
|
|
169
|
+
# refresh wants, so a thread for it would find the flight and exit.
|
|
170
|
+
# A caller naming required_credits still spawns: it has a reserve to
|
|
171
|
+
# retry, and may need more than the flight asked for.
|
|
172
|
+
return nil if required_credits.nil? && extend_in_flight?(company_id, credit_type_id)
|
|
173
|
+
|
|
174
|
+
# The whole call is tracked, not just the wire call inside it: callers
|
|
175
|
+
# drop the thread on the floor, so between the store read and the
|
|
176
|
+
# extend there would otherwise be a window where a drain sees nothing
|
|
177
|
+
# pending.
|
|
178
|
+
track do
|
|
179
|
+
extend_if_needed(company_id, credit_type_id, required_credits, request_options, deadline)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Refuse new lease work. Idempotent, and paired with drain: stopping
|
|
184
|
+
# first is what makes the drain terminate, since nothing can enqueue
|
|
185
|
+
# behind it. Taken under the flight lock that enlist reads it under, so
|
|
186
|
+
# once stop returns no further flight can register and the drain that
|
|
187
|
+
# follows sees every flight there will ever be.
|
|
188
|
+
def stop
|
|
189
|
+
@flight_mutex.synchronize { @stopped = true }
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Wait out lease work already on the wire, so a close releases what that
|
|
194
|
+
# work installs instead of orphaning it. Bounded: whatever has not
|
|
195
|
+
# landed by the deadline is abandoned rather than stalling the caller's
|
|
196
|
+
# shutdown, and the credits it holds fall back to server-side expiry.
|
|
197
|
+
def drain(timeout_ms = SHUTDOWN_DRAIN_TIMEOUT_MS)
|
|
198
|
+
deadline = monotonic_ms + timeout_ms
|
|
199
|
+
loop do
|
|
200
|
+
pending_threads, pending_flights = pending_work
|
|
201
|
+
return if pending_threads.empty? && pending_flights.empty?
|
|
202
|
+
|
|
203
|
+
remaining = deadline - monotonic_ms
|
|
204
|
+
if remaining <= 0
|
|
205
|
+
@logger.warn(
|
|
206
|
+
"Timed out after #{timeout_ms}ms draining in-flight credit lease work; " \
|
|
207
|
+
"any credits it holds will be released by server-side expiry"
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
# Recomputed per thread, not once for the round: a shared budget
|
|
212
|
+
# spent thread by thread would let N stalled threads wait N times
|
|
213
|
+
# the timeout a caller asked close to take.
|
|
214
|
+
pending_threads.each do |thread|
|
|
215
|
+
left = deadline - monotonic_ms
|
|
216
|
+
break if left <= 0
|
|
217
|
+
|
|
218
|
+
thread.join(left / 1000.0)
|
|
219
|
+
end
|
|
220
|
+
pending_flights.each { |flight| flight.wait(deadline - monotonic_ms) }
|
|
221
|
+
# Settling one round can enqueue another (an acquire that loses its
|
|
222
|
+
# race fires a release), so keep going until nothing is left.
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Release every live lease held in the store, returning its unspent
|
|
227
|
+
# remainder now rather than at expiry. ONLY safe per-process: a shared
|
|
228
|
+
# store's siblings are still drawing on those leases, which the list
|
|
229
|
+
# capability check excludes.
|
|
230
|
+
#
|
|
231
|
+
# timeout_ms bounds the whole pass, because each release is a
|
|
232
|
+
# synchronous round trip and a slow API would otherwise stretch close by
|
|
233
|
+
# one timeout per slot. What is left expires server-side, the same
|
|
234
|
+
# outcome a failed release already has.
|
|
235
|
+
def release_all_local_leases(timeout_ms = nil)
|
|
236
|
+
return nil unless @lease_store.respond_to?(:list)
|
|
237
|
+
|
|
238
|
+
# A failed listing is logged and left to server-side expiry: raising
|
|
239
|
+
# here would skip the rest of the caller's close.
|
|
240
|
+
entries = begin
|
|
241
|
+
@lease_store.list
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
@logger.warn("Failed to list credit leases on close (they will expire server-side): #{e.message}")
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
return nil if entries.nil? || entries.empty?
|
|
247
|
+
|
|
248
|
+
deadline = timeout_ms.nil? ? nil : monotonic_ms + timeout_ms
|
|
249
|
+
entries.each_with_index do |entry, index|
|
|
250
|
+
# Skip expired leases: the server already swept and refunded them.
|
|
251
|
+
next if entry.expired?(@clock.call)
|
|
252
|
+
|
|
253
|
+
if deadline && monotonic_ms >= deadline
|
|
254
|
+
left = entries[index..].count { |remaining| !remaining.expired?(@clock.call) }
|
|
255
|
+
@logger.warn(
|
|
256
|
+
"Timed out after #{timeout_ms.round}ms releasing credit leases on close; " \
|
|
257
|
+
"#{left} left to server-side expiry"
|
|
258
|
+
)
|
|
259
|
+
break
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
begin
|
|
263
|
+
@wire.release(lease_id: entry.lease_id)
|
|
264
|
+
@lease_store.drop(entry.company_id, entry.credit_type_id)
|
|
265
|
+
@logger.debug("Released credit lease #{entry.lease_id} on close")
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
@logger.warn(
|
|
268
|
+
"Failed to release credit lease #{entry.lease_id} on close " \
|
|
269
|
+
"(it will expire server-side): #{e.message}"
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private
|
|
277
|
+
|
|
278
|
+
def acquire(company_id, credit_type_id, request_options)
|
|
279
|
+
resolved = resolve_config(credit_type_id)
|
|
280
|
+
grant = @wire.acquire(
|
|
281
|
+
company_id: company_id,
|
|
282
|
+
credit_type_id: credit_type_id,
|
|
283
|
+
requested_amount: resolved.lease_size,
|
|
284
|
+
expires_at: @clock.call + (resolved.lease_duration_ms / 1000.0),
|
|
285
|
+
request_options: request_options || {}
|
|
286
|
+
)
|
|
287
|
+
wrote = @lease_store.replace(LeaseEntry.new(
|
|
288
|
+
lease_id: grant.lease_id,
|
|
289
|
+
company_id: grant.company_id,
|
|
290
|
+
credit_type_id: grant.credit_type_id,
|
|
291
|
+
granted_amount: grant.granted_amount,
|
|
292
|
+
expires_at: grant.expires_at
|
|
293
|
+
))
|
|
294
|
+
return @lease_store.get(company_id, credit_type_id) if wrote
|
|
295
|
+
|
|
296
|
+
settle_lost_race(company_id, credit_type_id, grant)
|
|
297
|
+
rescue StandardError => e
|
|
298
|
+
@logger.error("Failed to acquire credit lease for #{company_id}/#{credit_type_id}: #{e.message}")
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A sibling installed a live lease first, so replace kept theirs. The
|
|
303
|
+
# server is idempotent for an active slot, so ours is normally the SAME
|
|
304
|
+
# lease and releasing it would refund a lease every process is still
|
|
305
|
+
# reserving against. Only a DIFFERENT lease is a redundant hold worth
|
|
306
|
+
# releasing. An empty slot (it expired in the gap) is skipped too: that
|
|
307
|
+
# id may well be what the next acquire is handed back.
|
|
308
|
+
def settle_lost_race(company_id, credit_type_id, grant)
|
|
309
|
+
current = @lease_store.get(company_id, credit_type_id)
|
|
310
|
+
if current && current.lease_id != grant.lease_id
|
|
311
|
+
@logger.debug(
|
|
312
|
+
"Lost acquire race for #{company_id}/#{credit_type_id}; releasing redundant lease #{grant.lease_id}"
|
|
313
|
+
)
|
|
314
|
+
# Fire and forget: a failed release just falls back to lease expiry.
|
|
315
|
+
track do
|
|
316
|
+
@wire.release(lease_id: grant.lease_id)
|
|
317
|
+
rescue StandardError => e
|
|
318
|
+
@logger.warn("Failed to release redundant credit lease #{grant.lease_id}: #{e.message}")
|
|
319
|
+
end
|
|
320
|
+
else
|
|
321
|
+
@logger.debug(
|
|
322
|
+
"Lost acquire race for #{company_id}/#{credit_type_id}; " \
|
|
323
|
+
"server returned the installed lease #{grant.lease_id}, nothing to release"
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
current
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Cheap read-only answer to "would extend_if_needed do anything". It can
|
|
330
|
+
# go stale between here and the thread, which is harmless: the thread
|
|
331
|
+
# re-reads and re-decides under the flight.
|
|
332
|
+
def extend_due?(company_id, credit_type_id, required_credits)
|
|
333
|
+
entry = @lease_store.get(company_id, credit_type_id)
|
|
334
|
+
return false if entry.nil? || entry.expired?(@clock.call)
|
|
335
|
+
|
|
336
|
+
needs_extend?(entry, resolve_config(credit_type_id), required_credits)
|
|
337
|
+
rescue StandardError => e
|
|
338
|
+
# No extend without a reading: a store outage answering true would
|
|
339
|
+
# spawn a thread per check, and each would only fail the same read.
|
|
340
|
+
@logger.debug("Failed to read lease store for #{company_id}/#{credit_type_id}: #{e.message}")
|
|
341
|
+
false
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Joins are budgeted, extends of our own are not: a caller may wait out
|
|
345
|
+
# flights that ask for too little, but once the budget runs out it
|
|
346
|
+
# issues its own single extend rather than joining again. Without the
|
|
347
|
+
# budget a caller could wait behind an unbounded run of other callers'
|
|
348
|
+
# follow-ups; without the extend of its own it would return a balance it
|
|
349
|
+
# already knows is short and fail its retry with credits on the server.
|
|
350
|
+
def extend_if_needed(company_id, credit_type_id, required_credits, request_options, deadline = nil)
|
|
351
|
+
# A joiner waits on someone else's wire call, which runs on whatever
|
|
352
|
+
# timeout ITS caller set (a background refresh uses the client
|
|
353
|
+
# default). So the wait is capped at this caller's own deadline: a
|
|
354
|
+
# check with 200ms to spend must not sit behind a 30s extend. A check
|
|
355
|
+
# passes the deadline it fixed when it started, since one taken now
|
|
356
|
+
# would grant a fresh timeout on top of the acquire and reserve.
|
|
357
|
+
deadline ||= join_deadline(request_options)
|
|
358
|
+
joins_left = MAX_EXTEND_JOINS
|
|
359
|
+
loop do
|
|
360
|
+
entry = read_live_lease(company_id, credit_type_id)
|
|
361
|
+
return nil if entry.nil?
|
|
362
|
+
|
|
363
|
+
resolved = resolve_config(credit_type_id)
|
|
364
|
+
return entry unless needs_extend?(entry, resolved, required_credits)
|
|
365
|
+
|
|
366
|
+
# Sized to cover the request that triggered it, or a check needing
|
|
367
|
+
# more than the tranche would fail its post-extend retry forever
|
|
368
|
+
# against an ample server balance. Sized here rather than at the
|
|
369
|
+
# wire call so the flight below and the request body provably carry
|
|
370
|
+
# the same number for a joiner to compare against.
|
|
371
|
+
shortfall = required_credits ? required_credits - entry.local_remaining_credits : 0
|
|
372
|
+
additional_amount = [resolved.lease_size, shortfall].max
|
|
373
|
+
|
|
374
|
+
key = Leases.lease_key(company_id, credit_type_id)
|
|
375
|
+
# With the budget spent we take the slot ourselves rather than
|
|
376
|
+
# joining a flight that has already proved too small.
|
|
377
|
+
flight, leader = enlist(@inflight_extend, key, force: joins_left <= 0) { Flight.new(additional_amount) }
|
|
378
|
+
return log_stopped("extend", company_id, credit_type_id) if flight.nil?
|
|
379
|
+
return run_extend(key, flight, entry, resolved, additional_amount, request_options) if leader
|
|
380
|
+
|
|
381
|
+
# A watermark refresh that finds a flight already running has
|
|
382
|
+
# nothing to wait for: the credits it wants are the ones that call
|
|
383
|
+
# is fetching. Only a caller naming required_credits waits, because
|
|
384
|
+
# it has a reserve to retry.
|
|
385
|
+
return nil if required_credits.nil?
|
|
386
|
+
|
|
387
|
+
joined = join_within(flight, deadline)
|
|
388
|
+
if joined.equal?(JOIN_TIMED_OUT)
|
|
389
|
+
# The flight runs on for everybody else; we just stop waiting on
|
|
390
|
+
# it. Reporting no entry sends the caller down its fail-open or
|
|
391
|
+
# fail-closed path, which is what its timeout asked for.
|
|
392
|
+
@logger.debug(
|
|
393
|
+
"Extend in flight for #{company_id}/#{credit_type_id} outlasted the caller's timeout; " \
|
|
394
|
+
"not waiting on it"
|
|
395
|
+
)
|
|
396
|
+
return nil
|
|
397
|
+
end
|
|
398
|
+
# The flight already asked for at least what we need, which covers
|
|
399
|
+
# every watermark-driven joiner and any check the tranche covers.
|
|
400
|
+
# One wire call serves all of them, which is the point.
|
|
401
|
+
return joined if additional_amount <= flight.requested_additional
|
|
402
|
+
|
|
403
|
+
# It asked for less. Go round again to re-read the slot it just
|
|
404
|
+
# moved, so what we ask for next is sized against the balance it
|
|
405
|
+
# left rather than the one we started from.
|
|
406
|
+
joins_left -= 1
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Wait on another caller's acquire, reporting nil once the deadline
|
|
411
|
+
# passes. The acquire runs on for whoever else is on it, and what it
|
|
412
|
+
# installs is there for the next check to read.
|
|
413
|
+
def join_acquire(flight, deadline, company_id, credit_type_id)
|
|
414
|
+
joined = join_within(flight, deadline)
|
|
415
|
+
return joined unless joined.equal?(JOIN_TIMED_OUT)
|
|
416
|
+
|
|
417
|
+
@logger.debug(
|
|
418
|
+
"Acquire in flight for #{company_id}/#{credit_type_id} outlasted the caller's timeout; not waiting on it"
|
|
419
|
+
)
|
|
420
|
+
nil
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Read the slot, reporting nil when the read fails or the lease is
|
|
424
|
+
# absent or expired. An expired lease is never extended: the server
|
|
425
|
+
# treats it as released, with its remainder already refunded to the
|
|
426
|
+
# company balance, so the right move is the fresh acquire the next check
|
|
427
|
+
# performs.
|
|
428
|
+
def read_live_lease(company_id, credit_type_id)
|
|
429
|
+
entry = @lease_store.get(company_id, credit_type_id)
|
|
430
|
+
return nil if entry.nil? || entry.expired?(@clock.call)
|
|
431
|
+
|
|
432
|
+
entry
|
|
433
|
+
rescue StandardError => e
|
|
434
|
+
@logger.warn("Failed to read lease store for #{company_id}/#{credit_type_id}: #{e.message}")
|
|
435
|
+
nil
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Whether the entry sits low enough to warrant an extend.
|
|
439
|
+
def needs_extend?(entry, resolved, required_credits)
|
|
440
|
+
ratio = entry.local_remaining_credits / [entry.granted_amount, 1].max
|
|
441
|
+
below_watermark = ratio <= resolved.low_water_mark
|
|
442
|
+
below_required = !required_credits.nil? && entry.local_remaining_credits < required_credits
|
|
443
|
+
below_watermark || below_required
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# When a joiner's wait on a shared flight runs out, or nil for no cap.
|
|
447
|
+
def join_deadline(request_options)
|
|
448
|
+
Leases.join_deadline(request_options)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Wait out a flight somebody else is running, giving up at the deadline.
|
|
452
|
+
# Giving up abandons only our wait: the flight keeps running for the
|
|
453
|
+
# callers still on it, and whatever it installs is there for our next
|
|
454
|
+
# check to read.
|
|
455
|
+
def join_within(flight, deadline)
|
|
456
|
+
return flight.wait if deadline.nil?
|
|
457
|
+
|
|
458
|
+
remaining = deadline - monotonic_ms
|
|
459
|
+
return JOIN_TIMED_OUT if remaining <= 0
|
|
460
|
+
|
|
461
|
+
joined = flight.wait(remaining)
|
|
462
|
+
flight.done? ? joined : JOIN_TIMED_OUT
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Run the extend as the slot's in-flight one. The cleanup is
|
|
466
|
+
# identity-guarded rather than an unconditional delete: a joiner whose
|
|
467
|
+
# shortfall outran this flight registers a follow-up for the same key,
|
|
468
|
+
# and this flight must not evict it.
|
|
469
|
+
def run_extend(key, flight, entry, resolved, additional_amount, request_options)
|
|
470
|
+
result = extend(entry, resolved, additional_amount, request_options)
|
|
471
|
+
result
|
|
472
|
+
ensure
|
|
473
|
+
@flight_mutex.synchronize { @inflight_extend.delete(key) if @inflight_extend[key].equal?(flight) }
|
|
474
|
+
flight.complete(result)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def extend(entry, resolved, additional_amount, request_options)
|
|
478
|
+
grant = @wire.extend(
|
|
479
|
+
lease_id: entry.lease_id,
|
|
480
|
+
additional_amount: additional_amount,
|
|
481
|
+
expires_at: @clock.call + (resolved.lease_duration_ms / 1000.0),
|
|
482
|
+
# Minted once per extend, outside the wire call, so the transport's
|
|
483
|
+
# retries resend the same key: a retry after a lost 2xx is handed
|
|
484
|
+
# the lease as it stands instead of growing it a second time.
|
|
485
|
+
idempotency_key: SecureRandom.uuid,
|
|
486
|
+
request_options: request_options || {}
|
|
487
|
+
)
|
|
488
|
+
# Reconciled to the server's TOTAL, with the store computing the delta
|
|
489
|
+
# against its own current figure rather than the read above: two
|
|
490
|
+
# sibling processes extending one shared lease would each apply a
|
|
491
|
+
# stale-read delta and mint phantom credits. Pinned to the lease the
|
|
492
|
+
# server extended, so an expiry mid-call drops the delta instead of
|
|
493
|
+
# minting it onto the successor.
|
|
494
|
+
@lease_store.extend(entry.company_id, entry.credit_type_id, grant.granted_amount, grant.expires_at,
|
|
495
|
+
entry.lease_id)
|
|
496
|
+
@logger.debug(
|
|
497
|
+
"Extended credit lease #{entry.lease_id} to #{grant.granted_amount} " \
|
|
498
|
+
"(was #{entry.granted_amount} at last read)"
|
|
499
|
+
)
|
|
500
|
+
@lease_store.get(entry.company_id, entry.credit_type_id)
|
|
501
|
+
rescue StandardError => e
|
|
502
|
+
@logger.warn("Failed to extend credit lease #{entry.lease_id}: #{e.message}")
|
|
503
|
+
nil
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Register this caller against the slot's in-flight call, or become the
|
|
507
|
+
# one that makes it. Returns [flight, leader], or [nil, false] once the
|
|
508
|
+
# manager is stopped: the stopped read happens here, under the same lock
|
|
509
|
+
# stop takes, because an unguarded read outside it can be overtaken by a
|
|
510
|
+
# close whose drain then never sees the flight this would have made.
|
|
511
|
+
def enlist(flights, key, force: false)
|
|
512
|
+
@flight_mutex.synchronize do
|
|
513
|
+
next [nil, false] if @stopped
|
|
514
|
+
|
|
515
|
+
existing = flights[key]
|
|
516
|
+
next [existing, false] if existing && !force
|
|
517
|
+
|
|
518
|
+
flight = yield
|
|
519
|
+
flights[key] = flight
|
|
520
|
+
[flight, true]
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Whether the slot already has an extend on the wire.
|
|
525
|
+
def extend_in_flight?(company_id, credit_type_id)
|
|
526
|
+
@flight_mutex.synchronize { !@inflight_extend[Leases.lease_key(company_id, credit_type_id)].nil? }
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Hold a reference to work nobody joins so drain can wait it out.
|
|
530
|
+
def track(&block)
|
|
531
|
+
thread = Thread.new do
|
|
532
|
+
block.call
|
|
533
|
+
rescue StandardError => e
|
|
534
|
+
@logger.warn("Background credit lease work failed: #{e.message}")
|
|
535
|
+
nil
|
|
536
|
+
end
|
|
537
|
+
thread.abort_on_exception = false
|
|
538
|
+
@flight_mutex.synchronize do
|
|
539
|
+
# Finished work is dropped as new work arrives, so the list tracks
|
|
540
|
+
# what is still pending rather than growing for the process's life.
|
|
541
|
+
@background.select!(&:alive?)
|
|
542
|
+
@background << thread
|
|
543
|
+
end
|
|
544
|
+
thread
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def pending_work
|
|
548
|
+
@flight_mutex.synchronize do
|
|
549
|
+
@background.select!(&:alive?)
|
|
550
|
+
[@background.dup, (@inflight_acquire.values + @inflight_extend.values).reject(&:done?)]
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def log_stopped(action, company_id, credit_type_id)
|
|
555
|
+
@logger.debug("Lease manager is stopped; skipping #{action} for #{company_id}/#{credit_type_id}")
|
|
556
|
+
nil
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def monotonic_ms
|
|
560
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
end
|