lex-llm 0.6.16 → 0.7.2
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/CHANGELOG.md +29 -0
- data/lex-llm.gemspec +1 -1
- data/lib/legion/extensions/llm/auto_registration.rb +3 -0
- data/lib/legion/extensions/llm/canonical/message.rb +5 -1
- data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
- data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
- data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
- data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
- data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
- data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
- data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
- data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
- data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
- data/lib/legion/extensions/llm/inventory/records.rb +599 -0
- data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
- data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
- data/lib/legion/extensions/llm/provider.rb +55 -29
- data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
- data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
- data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
- data/lib/legion/extensions/llm/routing/records.rb +288 -0
- data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
- data/lib/legion/extensions/llm/taxonomies.rb +90 -0
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +18 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
- data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
- data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
- data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
- data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
- data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
- data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
- data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
- data/spec/legion/extensions/llm/provider_spec.rb +83 -0
- data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
- data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
- data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
- data/spec/support/fake_llm_provider.rb +11 -0
- data/spec/support/fake_ssot_harness.rb +137 -0
- data/spec/support/ssot_registry_helpers.rb +75 -0
- metadata +40 -3
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'legion/logging'
|
|
6
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
7
|
+
require 'legion/extensions/llm/inventory/identity'
|
|
8
|
+
require 'legion/extensions/llm/inventory/callable_handle'
|
|
9
|
+
require 'legion/extensions/llm/inventory/probe_token'
|
|
10
|
+
require 'legion/extensions/llm/inventory/records'
|
|
11
|
+
require 'legion/extensions/llm/inventory/snapshot'
|
|
12
|
+
|
|
13
|
+
module Legion
|
|
14
|
+
module Extensions
|
|
15
|
+
module Llm
|
|
16
|
+
module Inventory
|
|
17
|
+
# The only mutable SSOT owner: a module-level facade over one internal
|
|
18
|
+
# synchronized Store. Production exposes no constructor. See
|
|
19
|
+
# phase-1-lex-llm-additive.md section 12.
|
|
20
|
+
module Registry
|
|
21
|
+
# Private per-instance state. Never leaves registry.rb.
|
|
22
|
+
ScopeState = ::Data.define(
|
|
23
|
+
:instance_key, :publisher_token, :callable, :callable_handle, :probe_request_handle,
|
|
24
|
+
:publication_status, :last_sequence, :availability, :offerings, :lanes,
|
|
25
|
+
:availability_revision, :unavailable_revision, :published_at
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Private issued-probe tracking. Never leaves registry.rb.
|
|
29
|
+
IssuedProbeState = ::Data.define(:probe_token, :consumed)
|
|
30
|
+
|
|
31
|
+
# The synchronized internal store. All validation-against-current plus
|
|
32
|
+
# the root swap happen under @mutation_mutex; snapshot reads are
|
|
33
|
+
# lock-free. It never calls a provider callable, disconnect, probe
|
|
34
|
+
# enqueue handle, logger callback, or transport while holding the
|
|
35
|
+
# mutex; those side effects run after the swap. See section 12.2.
|
|
36
|
+
class Store
|
|
37
|
+
include Legion::Logging::Helper
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@scopes = Concurrent::Map.new
|
|
41
|
+
@issued_probe_tokens = Concurrent::Map.new
|
|
42
|
+
@generation = 0
|
|
43
|
+
@mutation_mutex = Mutex.new
|
|
44
|
+
@snapshot_ref = Concurrent::AtomicReference.new(build_snapshot_locked)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def snapshot
|
|
48
|
+
@snapshot_ref.get
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def claim_instance(instance_key:, callable:, probe_request_handle:)
|
|
52
|
+
validate_instance_key!(instance_key)
|
|
53
|
+
raise Errors::ValidationError, 'callable must not be nil' if callable.nil?
|
|
54
|
+
raise Errors::ValidationError, 'probe_request_handle must not be nil' if probe_request_handle.nil?
|
|
55
|
+
|
|
56
|
+
handle = CallableHandle.new(handle_id: "call:v1:#{SecureRandom.uuid}", callable: callable)
|
|
57
|
+
token = PublisherToken.issue(instance_key: instance_key)
|
|
58
|
+
old_handle = install_claim(instance_key, callable, handle, probe_request_handle, token)
|
|
59
|
+
old_handle&.retire
|
|
60
|
+
token
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def readiness_probe_started(instance_key:, publisher_token:)
|
|
64
|
+
validate_instance_key!(instance_key)
|
|
65
|
+
@mutation_mutex.synchronize do
|
|
66
|
+
scope = @scopes[instance_key]
|
|
67
|
+
classification = classify_publisher(scope, publisher_token, instance_key)
|
|
68
|
+
raise Errors::FencedPublisherError, 'superseded or invalid publisher token' unless classification == :current
|
|
69
|
+
|
|
70
|
+
issue_probe_token(scope)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def activate_instance_snapshot(publisher_token:, instance_key:, offerings:, sequence:, probe_token:)
|
|
75
|
+
validate_instance_key!(instance_key)
|
|
76
|
+
drafts = ensure_drafts!(offerings)
|
|
77
|
+
prepared = prepare_records(instance_key, drafts)
|
|
78
|
+
@mutation_mutex.synchronize do
|
|
79
|
+
scope = @scopes[instance_key]
|
|
80
|
+
guard = guard_current(scope, publisher_token, instance_key)
|
|
81
|
+
return guard if guard
|
|
82
|
+
raise Errors::InvalidTransitionError, 'activate requires an initializing claim' unless scope.publication_status.state == :initializing
|
|
83
|
+
return stale_mutation(scope, instance_key) unless prepared_matches?(prepared, scope)
|
|
84
|
+
|
|
85
|
+
consume_probe_token!(probe_token, scope, instance_key)
|
|
86
|
+
validate_sequence!(sequence, scope.last_sequence)
|
|
87
|
+
apply_activation(scope, instance_key, prepared, sequence, probe_token)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def replace_instance_snapshot(publisher_token:, instance_key:, offerings:, sequence:)
|
|
92
|
+
validate_instance_key!(instance_key)
|
|
93
|
+
drafts = ensure_drafts!(offerings)
|
|
94
|
+
prepared = prepare_records(instance_key, drafts)
|
|
95
|
+
@mutation_mutex.synchronize do
|
|
96
|
+
scope = @scopes[instance_key]
|
|
97
|
+
guard = guard_current(scope, publisher_token, instance_key)
|
|
98
|
+
return guard if guard
|
|
99
|
+
raise Errors::InvalidTransitionError, 'replace requires an activated instance' unless activated?(scope)
|
|
100
|
+
return stale_mutation(scope, instance_key) unless prepared_matches?(prepared, scope)
|
|
101
|
+
|
|
102
|
+
validate_sequence!(sequence, scope.last_sequence)
|
|
103
|
+
apply_replacement(scope, instance_key, prepared, sequence)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def readiness_succeeded(instance_key:, probe_token:)
|
|
108
|
+
validate_instance_key!(instance_key)
|
|
109
|
+
@mutation_mutex.synchronize do
|
|
110
|
+
scope = @scopes[instance_key]
|
|
111
|
+
return absent_stale(instance_key) if scope.nil?
|
|
112
|
+
|
|
113
|
+
probe_guard = guard_probe_publisher(scope, probe_token, instance_key)
|
|
114
|
+
return probe_guard if probe_guard
|
|
115
|
+
raise Errors::InvalidTransitionError, 'readiness_succeeded before activation' if scope.publication_status.state == :initializing
|
|
116
|
+
|
|
117
|
+
consume_probe_token!(probe_token, scope, instance_key)
|
|
118
|
+
apply_readiness_success(scope, instance_key, probe_token)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def readiness_failed(instance_key:, probe_token:, reason:)
|
|
123
|
+
validate_instance_key!(instance_key)
|
|
124
|
+
@mutation_mutex.synchronize do
|
|
125
|
+
scope = @scopes[instance_key]
|
|
126
|
+
return absent_stale(instance_key) if scope.nil?
|
|
127
|
+
|
|
128
|
+
probe_guard = guard_probe_publisher(scope, probe_token, instance_key)
|
|
129
|
+
return probe_guard if probe_guard
|
|
130
|
+
|
|
131
|
+
consume_probe_token!(probe_token, scope, instance_key)
|
|
132
|
+
apply_readiness_failure(scope, instance_key, probe_token, reason)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def dispatch_instance_unavailable(instance_key:, publisher_token_id:, reason:)
|
|
137
|
+
validate_instance_key!(instance_key)
|
|
138
|
+
side_effect = nil
|
|
139
|
+
result =
|
|
140
|
+
@mutation_mutex.synchronize do
|
|
141
|
+
scope = @scopes[instance_key]
|
|
142
|
+
return absent_stale(instance_key) if scope.nil?
|
|
143
|
+
return stale_mutation(scope, instance_key) unless scope.publisher_token.publisher_token_id == publisher_token_id
|
|
144
|
+
raise Errors::InvalidTransitionError, 'dispatch_instance_unavailable requires an activated instance' unless activated?(scope)
|
|
145
|
+
|
|
146
|
+
mutation, side_effect = apply_dispatch_unavailable(scope, instance_key, reason)
|
|
147
|
+
mutation
|
|
148
|
+
end
|
|
149
|
+
enqueue_probe(*side_effect) if side_effect
|
|
150
|
+
result
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def remove_instance(instance_key:, publisher_token:)
|
|
154
|
+
validate_instance_key!(instance_key)
|
|
155
|
+
old_handle = nil
|
|
156
|
+
result =
|
|
157
|
+
@mutation_mutex.synchronize do
|
|
158
|
+
scope = @scopes[instance_key]
|
|
159
|
+
return absent_removed(instance_key) if scope.nil?
|
|
160
|
+
|
|
161
|
+
guard = guard_current(scope, publisher_token, instance_key)
|
|
162
|
+
return guard if guard
|
|
163
|
+
|
|
164
|
+
old_handle = scope.callable_handle
|
|
165
|
+
@scopes.delete(instance_key)
|
|
166
|
+
bump_and_snapshot!
|
|
167
|
+
MutationResult.new(applied: true, reason: :removed, generation: @generation, instance_key: instance_key)
|
|
168
|
+
end
|
|
169
|
+
old_handle&.retire
|
|
170
|
+
result
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def acquire(callable_handle:)
|
|
174
|
+
raise Errors::UnknownCallableError, 'acquire requires a CallableHandle' unless callable_handle.is_a?(CallableHandle)
|
|
175
|
+
|
|
176
|
+
callable_handle.acquire
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def retire_all_handles
|
|
180
|
+
@scopes.each_value { |scope| scope.callable_handle.retire }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
# --- Claim -------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def install_claim(instance_key, callable, handle, probe_request_handle, token)
|
|
188
|
+
@mutation_mutex.synchronize do
|
|
189
|
+
prior = @scopes[instance_key]
|
|
190
|
+
raise Errors::ValidationError, 'a new claim must provide a distinct callable object' if prior && prior.callable.equal?(callable)
|
|
191
|
+
|
|
192
|
+
status = PublicationStatus.new(
|
|
193
|
+
instance_key: instance_key, state: :initializing,
|
|
194
|
+
publisher_token_id: token.publisher_token_id, published_sequence: nil
|
|
195
|
+
)
|
|
196
|
+
@scopes[instance_key] = ScopeState.new(
|
|
197
|
+
instance_key: instance_key, publisher_token: token, callable: callable, callable_handle: handle,
|
|
198
|
+
probe_request_handle: probe_request_handle, publication_status: status,
|
|
199
|
+
last_sequence: -1, availability: nil, offerings: {}, lanes: {},
|
|
200
|
+
availability_revision: prior&.availability_revision || 0,
|
|
201
|
+
unavailable_revision: prior&.unavailable_revision, published_at: nil
|
|
202
|
+
)
|
|
203
|
+
bump_and_snapshot!
|
|
204
|
+
prior&.callable_handle
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# --- Activation --------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def apply_activation(scope, instance_key, prepared, sequence, probe_token)
|
|
211
|
+
now = Time.now
|
|
212
|
+
new_rev = scope.availability_revision + 1
|
|
213
|
+
availability = AvailabilityFact.new(
|
|
214
|
+
state: :available, availability_revision: new_rev, source: :startup_readiness,
|
|
215
|
+
reason: 'startup readiness succeeded', observed_at: now,
|
|
216
|
+
last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
|
|
217
|
+
)
|
|
218
|
+
status = complete_status(scope, sequence, probe_token, now, :success)
|
|
219
|
+
new_scope = scope.with(
|
|
220
|
+
publication_status: status, last_sequence: sequence, availability: availability,
|
|
221
|
+
offerings: prepared[1], lanes: prepared[2], availability_revision: new_rev,
|
|
222
|
+
unavailable_revision: nil, published_at: now
|
|
223
|
+
)
|
|
224
|
+
@scopes[instance_key] = new_scope
|
|
225
|
+
bump_and_snapshot!
|
|
226
|
+
applied(:activated, new_scope)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def complete_status(scope, sequence, probe_token, now, outcome)
|
|
230
|
+
scope.publication_status.with(
|
|
231
|
+
state: :complete, published_sequence: sequence, last_probe_started_at: probe_token.started_at,
|
|
232
|
+
last_probe_completed_at: now, last_probe_outcome: outcome
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# --- Replacement -------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def apply_replacement(scope, instance_key, prepared, sequence)
|
|
239
|
+
status = scope.publication_status.with(published_sequence: sequence)
|
|
240
|
+
new_scope = scope.with(offerings: prepared[1], lanes: prepared[2], last_sequence: sequence, publication_status: status)
|
|
241
|
+
@scopes[instance_key] = new_scope
|
|
242
|
+
bump_and_snapshot!
|
|
243
|
+
applied(:snapshot_replaced, new_scope)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# --- Readiness success -------------------------------------------
|
|
247
|
+
|
|
248
|
+
def apply_readiness_success(scope, instance_key, probe_token)
|
|
249
|
+
if scope.availability.state == :available
|
|
250
|
+
observe_available_success(scope, instance_key, probe_token)
|
|
251
|
+
elsif probe_token.started_availability_revision >= scope.unavailable_revision
|
|
252
|
+
recover_instance(scope, instance_key, probe_token)
|
|
253
|
+
else
|
|
254
|
+
MutationResult.new(
|
|
255
|
+
applied: false, reason: :stale_probe, generation: @generation, instance_key: instance_key,
|
|
256
|
+
publication_status: scope.publication_status, instance_record: instance_record_for(scope)
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def observe_available_success(scope, instance_key, probe_token)
|
|
262
|
+
now = Time.now
|
|
263
|
+
availability = scope.availability.with(
|
|
264
|
+
source: :readiness, last_probe_started_at: probe_token.started_at,
|
|
265
|
+
last_probe_completed_at: now, last_probe_outcome: :success
|
|
266
|
+
)
|
|
267
|
+
status = scope.publication_status.with(
|
|
268
|
+
last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
|
|
269
|
+
)
|
|
270
|
+
new_scope = scope.with(availability: availability, publication_status: status)
|
|
271
|
+
@scopes[instance_key] = new_scope
|
|
272
|
+
bump_and_snapshot!
|
|
273
|
+
applied(:readiness_observed, new_scope)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def recover_instance(scope, instance_key, probe_token)
|
|
277
|
+
now = Time.now
|
|
278
|
+
new_rev = scope.availability_revision + 1
|
|
279
|
+
availability = AvailabilityFact.new(
|
|
280
|
+
state: :available, availability_revision: new_rev, source: :readiness,
|
|
281
|
+
reason: 'readiness recovered exact instance', observed_at: now,
|
|
282
|
+
last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
|
|
283
|
+
)
|
|
284
|
+
status = scope.publication_status.with(
|
|
285
|
+
last_probe_started_at: probe_token.started_at, last_probe_completed_at: now, last_probe_outcome: :success
|
|
286
|
+
)
|
|
287
|
+
new_scope = scope.with(
|
|
288
|
+
availability: availability, availability_revision: new_rev, unavailable_revision: nil, publication_status: status
|
|
289
|
+
)
|
|
290
|
+
@scopes[instance_key] = new_scope
|
|
291
|
+
bump_and_snapshot!
|
|
292
|
+
applied(:instance_recovered, new_scope)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# --- Readiness failure -------------------------------------------
|
|
296
|
+
|
|
297
|
+
def apply_readiness_failure(scope, instance_key, probe_token, reason)
|
|
298
|
+
now = Time.now
|
|
299
|
+
if scope.publication_status.state == :initializing
|
|
300
|
+
status = scope.publication_status.with(
|
|
301
|
+
last_probe_started_at: probe_token.started_at, last_probe_completed_at: now,
|
|
302
|
+
last_probe_outcome: :failure, last_error: reason
|
|
303
|
+
)
|
|
304
|
+
new_scope = scope.with(publication_status: status)
|
|
305
|
+
@scopes[instance_key] = new_scope
|
|
306
|
+
bump_and_snapshot!
|
|
307
|
+
return MutationResult.new(
|
|
308
|
+
applied: true, reason: :initial_readiness_failed, generation: @generation,
|
|
309
|
+
instance_key: instance_key, publication_status: status
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
mark_unavailable_from_readiness(scope, instance_key, probe_token, reason, now)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def mark_unavailable_from_readiness(scope, instance_key, probe_token, reason, now)
|
|
316
|
+
new_rev = scope.availability_revision + 1
|
|
317
|
+
availability = AvailabilityFact.new(
|
|
318
|
+
state: :unavailable, availability_revision: new_rev, source: :readiness, reason: reason, observed_at: now,
|
|
319
|
+
last_probe_started_at: probe_token.started_at, last_probe_completed_at: now,
|
|
320
|
+
last_probe_outcome: :failure, unavailable_revision: new_rev
|
|
321
|
+
)
|
|
322
|
+
status = scope.publication_status.with(
|
|
323
|
+
last_probe_started_at: probe_token.started_at, last_probe_completed_at: now,
|
|
324
|
+
last_probe_outcome: :failure, last_error: reason
|
|
325
|
+
)
|
|
326
|
+
new_scope = scope.with(
|
|
327
|
+
availability: availability, availability_revision: new_rev, unavailable_revision: new_rev, publication_status: status
|
|
328
|
+
)
|
|
329
|
+
@scopes[instance_key] = new_scope
|
|
330
|
+
bump_and_snapshot!
|
|
331
|
+
applied(:instance_unavailable, new_scope)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# --- Dispatch-reported unavailable -------------------------------
|
|
335
|
+
|
|
336
|
+
def apply_dispatch_unavailable(scope, instance_key, reason)
|
|
337
|
+
now = Time.now
|
|
338
|
+
new_rev = scope.availability_revision + 1
|
|
339
|
+
availability = AvailabilityFact.new(
|
|
340
|
+
state: :unavailable, availability_revision: new_rev, source: :dispatch, reason: reason,
|
|
341
|
+
observed_at: now, unavailable_revision: new_rev
|
|
342
|
+
)
|
|
343
|
+
new_scope = scope.with(availability: availability, availability_revision: new_rev, unavailable_revision: new_rev)
|
|
344
|
+
@scopes[instance_key] = new_scope
|
|
345
|
+
bump_and_snapshot!
|
|
346
|
+
[applied(:instance_unavailable, new_scope),
|
|
347
|
+
[scope.probe_request_handle, instance_key, scope.publisher_token.publisher_token_id, new_rev, reason]]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def enqueue_probe(handle, instance_key, token_id, unavailable_revision, reason)
|
|
351
|
+
handle.enqueue_probe_request(
|
|
352
|
+
instance_key: instance_key, publisher_token_id: token_id,
|
|
353
|
+
unavailable_revision: unavailable_revision, reason: reason
|
|
354
|
+
)
|
|
355
|
+
rescue StandardError => e
|
|
356
|
+
handle_exception(
|
|
357
|
+
e, handled: true, level: :warn, operation: 'llm.inventory.registry.enqueue_probe',
|
|
358
|
+
provider_family: instance_key.provider_family, instance_id: instance_key.instance_id
|
|
359
|
+
)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# --- Probe token single-use --------------------------------------
|
|
363
|
+
|
|
364
|
+
def issue_probe_token(scope)
|
|
365
|
+
token = ProbeToken.issue(
|
|
366
|
+
instance_key: scope.instance_key, publisher_token_id: scope.publisher_token.publisher_token_id,
|
|
367
|
+
started_availability_revision: scope.availability_revision, started_at: Time.now
|
|
368
|
+
)
|
|
369
|
+
@issued_probe_tokens[token.token_id] = IssuedProbeState.new(probe_token: token, consumed: false)
|
|
370
|
+
status = scope.publication_status.with(last_probe_started_at: token.started_at)
|
|
371
|
+
@scopes[scope.instance_key] = scope.with(publication_status: status)
|
|
372
|
+
bump_and_snapshot!
|
|
373
|
+
token
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def consume_probe_token!(probe_token, scope, instance_key)
|
|
377
|
+
validate_probe_token_shape!(probe_token, instance_key)
|
|
378
|
+
issued = @issued_probe_tokens[probe_token.token_id]
|
|
379
|
+
raise Errors::InvalidProbeError, 'unknown probe token' if issued.nil?
|
|
380
|
+
raise Errors::InvalidProbeError, 'probe token already consumed' if issued.consumed
|
|
381
|
+
raise Errors::InvalidProbeError, 'probe token does not belong to the current publisher' unless probe_token.publisher_token_id == scope.publisher_token.publisher_token_id
|
|
382
|
+
|
|
383
|
+
@issued_probe_tokens[probe_token.token_id] = issued.with(consumed: true)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def validate_probe_token_shape!(probe_token, instance_key)
|
|
387
|
+
return if probe_token.is_a?(ProbeToken) && probe_token.instance_key == instance_key
|
|
388
|
+
|
|
389
|
+
raise Errors::InvalidProbeError, 'probe token is malformed or for another instance'
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# --- Fencing -----------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def classify_publisher(scope, token, instance_key)
|
|
395
|
+
return :fenced unless token.is_a?(PublisherToken) && token.instance_key == instance_key
|
|
396
|
+
return :stale if scope.nil?
|
|
397
|
+
|
|
398
|
+
if scope.publisher_token.publisher_token_id == token.publisher_token_id
|
|
399
|
+
scope.publisher_token.authenticates?(token) ? :current : :fenced
|
|
400
|
+
else
|
|
401
|
+
:stale
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Returns a MutationResult when the publisher is not current, nil when
|
|
406
|
+
# it is current. Raises FencedPublisherError for a forged/malformed
|
|
407
|
+
# token.
|
|
408
|
+
def guard_current(scope, token, instance_key)
|
|
409
|
+
case classify_publisher(scope, token, instance_key)
|
|
410
|
+
when :fenced then raise Errors::FencedPublisherError, 'invalid or forged publisher token'
|
|
411
|
+
when :stale then stale_mutation(scope, instance_key)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def guard_probe_publisher(scope, probe_token, instance_key)
|
|
416
|
+
validate_probe_token_shape!(probe_token, instance_key)
|
|
417
|
+
return nil if probe_token.publisher_token_id == scope.publisher_token.publisher_token_id
|
|
418
|
+
|
|
419
|
+
stale_mutation(scope, instance_key)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# --- Snapshot construction ---------------------------------------
|
|
423
|
+
|
|
424
|
+
def bump_and_snapshot!
|
|
425
|
+
@generation += 1
|
|
426
|
+
@snapshot_ref.set(build_snapshot_locked)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def build_snapshot_locked
|
|
430
|
+
instances = {}
|
|
431
|
+
offerings = {}
|
|
432
|
+
lanes = {}
|
|
433
|
+
statuses = {}
|
|
434
|
+
@scopes.each_pair do |key, scope|
|
|
435
|
+
statuses[key] = scope.publication_status
|
|
436
|
+
next if scope.availability.nil?
|
|
437
|
+
|
|
438
|
+
instances[key] = instance_record_for(scope)
|
|
439
|
+
offerings.merge!(scope.offerings)
|
|
440
|
+
lanes.merge!(scope.lanes)
|
|
441
|
+
end
|
|
442
|
+
Snapshot.new(
|
|
443
|
+
generation: @generation, instances_by_key: instances, offerings_by_id: offerings,
|
|
444
|
+
lanes_by_id: lanes, publication_status_by_key: statuses
|
|
445
|
+
)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def instance_record_for(scope)
|
|
449
|
+
InstanceRecord.new(
|
|
450
|
+
instance_key: scope.instance_key, callable_handle: scope.callable_handle, availability: scope.availability,
|
|
451
|
+
offerings_by_id: scope.offerings, publisher_id: scope.publisher_token.publisher_id,
|
|
452
|
+
publisher_token_id: scope.publisher_token.publisher_token_id, published_sequence: scope.last_sequence,
|
|
453
|
+
published_at: scope.published_at
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# --- Record derivation (pure; no I/O or application callbacks) ----
|
|
458
|
+
|
|
459
|
+
def prepare_records(instance_key, drafts)
|
|
460
|
+
scope = @scopes[instance_key]
|
|
461
|
+
return nil if scope.nil?
|
|
462
|
+
|
|
463
|
+
offerings_by_id, lanes_by_id = build_records(instance_key, scope.callable_handle, drafts)
|
|
464
|
+
[scope.callable_handle, offerings_by_id, lanes_by_id]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def prepared_matches?(prepared, scope)
|
|
468
|
+
!prepared.nil? && prepared[0].equal?(scope.callable_handle)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def build_records(instance_key, handle, drafts)
|
|
472
|
+
offerings_by_id = {}
|
|
473
|
+
native_keys = {}
|
|
474
|
+
drafts.each do |draft|
|
|
475
|
+
offering_id = Identity.offering_id(instance_key: instance_key, provider_native_key: draft.provider_native_key)
|
|
476
|
+
raise Errors::ValidationError, 'duplicate provider_native_key' if native_keys.key?(draft.provider_native_key)
|
|
477
|
+
raise Errors::ValidationError, 'duplicate offering_id' if offerings_by_id.key?(offering_id)
|
|
478
|
+
|
|
479
|
+
native_keys[draft.provider_native_key] = true
|
|
480
|
+
offerings_by_id[offering_id] = build_offering_record(instance_key, handle, offering_id, draft)
|
|
481
|
+
end
|
|
482
|
+
[offerings_by_id.freeze, build_lanes(instance_key, handle, offerings_by_id).freeze]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def build_offering_record(instance_key, handle, offering_id, draft)
|
|
486
|
+
OfferingRecord.new(
|
|
487
|
+
offering_id: offering_id, provider_native_key: draft.provider_native_key, instance_key: instance_key,
|
|
488
|
+
model: draft.model, tier: draft.tier, operation_evidence: draft.operation_evidence,
|
|
489
|
+
capability_evidence: draft.capability_evidence, context_evidence: draft.context_evidence,
|
|
490
|
+
max_output_evidence: draft.max_output_evidence,
|
|
491
|
+
embedding_dimensions_evidence: draft.embedding_dimensions_evidence,
|
|
492
|
+
model_revision_evidence: draft.model_revision_evidence, tokenizer_evidence: draft.tokenizer_evidence,
|
|
493
|
+
quota_domains: draft.quota_domains, metadata: draft.metadata, callable_handle: handle,
|
|
494
|
+
publication_source: draft.publication_source
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def build_lanes(instance_key, handle, offerings_by_id)
|
|
499
|
+
lanes = {}
|
|
500
|
+
offerings_by_id.each_value do |offering|
|
|
501
|
+
offering.supported_operations.each do |operation|
|
|
502
|
+
lane_id = Identity.lane_id(
|
|
503
|
+
instance_key: instance_key, operation: operation, model: offering.model, offering_id: offering.offering_id
|
|
504
|
+
)
|
|
505
|
+
raise Errors::ValidationError, 'duplicate lane_id' if lanes.key?(lane_id)
|
|
506
|
+
|
|
507
|
+
lanes[lane_id] = build_lane_record(instance_key, handle, offering, operation, lane_id)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
lanes
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def build_lane_record(instance_key, handle, offering, operation, lane_id)
|
|
514
|
+
LaneRecord.new(
|
|
515
|
+
lane_id: lane_id, offering_id: offering.offering_id, instance_key: instance_key,
|
|
516
|
+
provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
|
|
517
|
+
model: offering.model, tier: offering.tier, operation: operation,
|
|
518
|
+
capability_evidence: offering.capability_evidence, context_evidence: offering.context_evidence,
|
|
519
|
+
max_output_evidence: offering.max_output_evidence,
|
|
520
|
+
embedding_dimensions_evidence: offering.embedding_dimensions_evidence,
|
|
521
|
+
model_revision_evidence: offering.model_revision_evidence, tokenizer_evidence: offering.tokenizer_evidence,
|
|
522
|
+
quota_domain: offering.quota_domain(operation: operation), metadata: offering.metadata,
|
|
523
|
+
callable_handle: handle, publication_source: offering.publication_source
|
|
524
|
+
)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# --- Small validators and result builders ------------------------
|
|
528
|
+
|
|
529
|
+
def ensure_drafts!(offerings)
|
|
530
|
+
raise Errors::ValidationError, 'offerings must be an Array of OfferingDraft' unless offerings.is_a?(::Array)
|
|
531
|
+
raise Errors::ValidationError, 'offerings must contain only OfferingDraft values' unless offerings.all?(OfferingDraft)
|
|
532
|
+
|
|
533
|
+
offerings
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def validate_instance_key!(instance_key)
|
|
537
|
+
raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def validate_sequence!(sequence, last_sequence)
|
|
541
|
+
return if sequence.is_a?(::Integer) && !sequence.negative? && sequence > last_sequence
|
|
542
|
+
|
|
543
|
+
raise Errors::StaleSequenceError, 'sequence must be a nonnegative Integer greater than the last accepted sequence'
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def activated?(scope)
|
|
547
|
+
!scope.availability.nil? && scope.publication_status.state == :complete
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def applied(reason, scope)
|
|
551
|
+
MutationResult.new(
|
|
552
|
+
applied: true, reason: reason, generation: @generation, instance_key: scope.instance_key,
|
|
553
|
+
publication_status: scope.publication_status, instance_record: instance_record_for(scope)
|
|
554
|
+
)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def stale_mutation(scope, instance_key)
|
|
558
|
+
MutationResult.new(
|
|
559
|
+
applied: false, reason: :stale_publisher, generation: @generation, instance_key: instance_key,
|
|
560
|
+
publication_status: scope&.publication_status,
|
|
561
|
+
instance_record: scope&.availability ? instance_record_for(scope) : nil
|
|
562
|
+
)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def absent_stale(instance_key)
|
|
566
|
+
MutationResult.new(applied: false, reason: :stale_publisher, generation: @generation, instance_key: instance_key)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def absent_removed(instance_key)
|
|
570
|
+
MutationResult.new(applied: false, reason: :already_removed, generation: @generation, instance_key: instance_key)
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
class << self
|
|
575
|
+
def claim_instance(...)
|
|
576
|
+
store.claim_instance(...)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def readiness_probe_started(...)
|
|
580
|
+
store.readiness_probe_started(...)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def activate_instance_snapshot(...)
|
|
584
|
+
store.activate_instance_snapshot(...)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def replace_instance_snapshot(...)
|
|
588
|
+
store.replace_instance_snapshot(...)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def readiness_succeeded(...)
|
|
592
|
+
store.readiness_succeeded(...)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def readiness_failed(...)
|
|
596
|
+
store.readiness_failed(...)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def dispatch_instance_unavailable(...)
|
|
600
|
+
store.dispatch_instance_unavailable(...)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def remove_instance(...)
|
|
604
|
+
store.remove_instance(...)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def snapshot
|
|
608
|
+
store.snapshot
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def acquire(...)
|
|
612
|
+
store.acquire(...)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Test-only: replace the store with a fresh generation-zero store and
|
|
616
|
+
# retire prior handles outside its mutation mutex.
|
|
617
|
+
def reset!
|
|
618
|
+
raise Errors::InvalidTransitionError, 'Registry.reset! is only available under RSpec' unless defined?(::RSpec)
|
|
619
|
+
|
|
620
|
+
previous = @store
|
|
621
|
+
@store = Store.new
|
|
622
|
+
previous&.retire_all_handles
|
|
623
|
+
@store
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
private
|
|
627
|
+
|
|
628
|
+
def store
|
|
629
|
+
@store ||= Store.new
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|