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,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
5
|
+
require_relative '../../../../support/ssot_registry_helpers'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
8
|
+
include SsotRegistryHelpers
|
|
9
|
+
|
|
10
|
+
inventory = Legion::Extensions::Llm::Inventory
|
|
11
|
+
errors = inventory::Errors
|
|
12
|
+
|
|
13
|
+
before { described_class.reset! }
|
|
14
|
+
|
|
15
|
+
let(:key) { instance_key }
|
|
16
|
+
let(:callable) { fake_callable }
|
|
17
|
+
let(:coordinator) { probe_coordinator(key) }
|
|
18
|
+
|
|
19
|
+
describe 'empty boot snapshot' do
|
|
20
|
+
it 'exposes generation zero and no records' do
|
|
21
|
+
snapshot = described_class.snapshot
|
|
22
|
+
expect(snapshot.generation).to eq(0)
|
|
23
|
+
expect(snapshot.each_lane.to_a).to be_empty
|
|
24
|
+
expect(snapshot.each_offering.to_a).to be_empty
|
|
25
|
+
expect(snapshot.instance(instance_key: key)).to be_nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
describe 'claim' do
|
|
30
|
+
it 'shows an initializing publication status with no active model or callable' do
|
|
31
|
+
described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
32
|
+
snapshot = described_class.snapshot
|
|
33
|
+
status = snapshot.publication_status(instance_key: key)
|
|
34
|
+
expect(status.state).to eq(:initializing)
|
|
35
|
+
expect(snapshot.instance(instance_key: key)).to be_nil
|
|
36
|
+
expect(snapshot.lanes_for(instance_key: key)).to be_empty
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'returns an opaque PublisherToken' do
|
|
40
|
+
token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
41
|
+
expect(token).to be_a(inventory::PublisherToken)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'rejects reusing the exact same callable object on re-claim' do
|
|
45
|
+
described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
46
|
+
expect { described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator) }
|
|
47
|
+
.to raise_error(errors::ValidationError)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'fences the superseded token after a fresh re-claim with a distinct callable' do
|
|
51
|
+
token1 = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
52
|
+
described_class.claim_instance(instance_key: key, callable: fake_callable, probe_request_handle: coordinator)
|
|
53
|
+
expect { described_class.readiness_probe_started(instance_key: key, publisher_token: token1) }
|
|
54
|
+
.to raise_error(errors::FencedPublisherError)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe 'startup activation sequence' do
|
|
59
|
+
it 'raises if readiness_succeeded is called before activation' do
|
|
60
|
+
token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
61
|
+
probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
62
|
+
expect { described_class.readiness_succeeded(instance_key: key, probe_token: probe) }
|
|
63
|
+
.to raise_error(errors::InvalidTransitionError)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'atomically publishes callable, offerings, lanes, and available on activation' do
|
|
67
|
+
claim_and_activate(key: key, callable: callable, coordinator: coordinator)
|
|
68
|
+
snapshot = described_class.snapshot
|
|
69
|
+
record = snapshot.instance(instance_key: key)
|
|
70
|
+
expect(record).not_to be_nil
|
|
71
|
+
expect(record.availability.state).to eq(:available)
|
|
72
|
+
expect(snapshot.lanes_for(instance_key: key).size).to eq(1)
|
|
73
|
+
expect(snapshot.offerings_for(instance_key: key).size).to eq(1)
|
|
74
|
+
expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'accepts an explicit complete empty activation as complete, not initializing' do
|
|
78
|
+
token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
79
|
+
probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
80
|
+
result = described_class.activate_instance_snapshot(
|
|
81
|
+
publisher_token: token, instance_key: key, offerings: [], sequence: 0, probe_token: probe
|
|
82
|
+
)
|
|
83
|
+
expect(result.applied).to be(true)
|
|
84
|
+
snapshot = described_class.snapshot
|
|
85
|
+
expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
|
|
86
|
+
expect(snapshot.instance(instance_key: key).offerings_by_id).to eq({})
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'consumes the probe token so it cannot be reused' do
|
|
90
|
+
token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
91
|
+
probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
92
|
+
described_class.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 0, probe_token: probe)
|
|
93
|
+
expect { described_class.readiness_succeeded(instance_key: key, probe_token: probe) }
|
|
94
|
+
.to raise_error(errors::InvalidProbeError)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'requires a sequence greater than the claim baseline' do
|
|
98
|
+
token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
99
|
+
probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
100
|
+
expect do
|
|
101
|
+
described_class.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: -1, probe_token: probe)
|
|
102
|
+
end.to raise_error(errors::StaleSequenceError)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'increments generation once for a claim' do
|
|
106
|
+
generation = described_class.snapshot.generation
|
|
107
|
+
described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
108
|
+
expect(described_class.snapshot.generation).to eq(generation + 1)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe 'initial readiness failure' do
|
|
113
|
+
it 'leaves the instance initializing, exposes no model/callable, and does not mark unavailable' do
|
|
114
|
+
token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: coordinator)
|
|
115
|
+
probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
116
|
+
result = described_class.readiness_failed(instance_key: key, probe_token: probe, reason: 'probe failed')
|
|
117
|
+
expect(result.applied).to be(true)
|
|
118
|
+
expect(result.reason).to eq(:initial_readiness_failed)
|
|
119
|
+
snapshot = described_class.snapshot
|
|
120
|
+
expect(snapshot.instance(instance_key: key)).to be_nil
|
|
121
|
+
expect(snapshot.publication_status(instance_key: key).state).to eq(:initializing)
|
|
122
|
+
expect(snapshot.lanes_for(instance_key: key)).to be_empty
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe 'no external calls under the mutation mutex' do
|
|
127
|
+
it 'never invokes the callable disconnect or a probe enqueue while activating' do
|
|
128
|
+
enqueue_calls = []
|
|
129
|
+
probing = probe_coordinator(key, enqueue: lambda { |request:|
|
|
130
|
+
enqueue_calls << request
|
|
131
|
+
true
|
|
132
|
+
})
|
|
133
|
+
token = described_class.claim_instance(instance_key: key, callable: callable, probe_request_handle: probing)
|
|
134
|
+
probe = described_class.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
135
|
+
described_class.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 0, probe_token: probe)
|
|
136
|
+
expect(callable.disconnect_count).to eq(0)
|
|
137
|
+
expect(enqueue_calls).to be_empty
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
5
|
+
require_relative '../../../../support/ssot_registry_helpers'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
8
|
+
include SsotRegistryHelpers
|
|
9
|
+
|
|
10
|
+
errors = Legion::Extensions::Llm::Inventory::Errors
|
|
11
|
+
|
|
12
|
+
before { described_class.reset! }
|
|
13
|
+
|
|
14
|
+
let(:key_a) { instance_key(instance: 'h200') }
|
|
15
|
+
let(:key_b) { instance_key(instance: 'helios1') }
|
|
16
|
+
|
|
17
|
+
def revision_of(key)
|
|
18
|
+
described_class.snapshot.instance(instance_key: key)&.availability&.availability_revision
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def state_of(key)
|
|
22
|
+
described_class.snapshot.instance(instance_key: key)&.availability&.state
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def probe(token, key)
|
|
26
|
+
described_class.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
describe 'ordinary readiness success while available' do
|
|
30
|
+
it 'records telemetry without changing availability revision or eligibility' do
|
|
31
|
+
token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
32
|
+
expect(revision_of(key_a)).to eq(1)
|
|
33
|
+
result = described_class.readiness_succeeded(instance_key: key_a, probe_token: probe(token, key_a))
|
|
34
|
+
expect(result.reason).to eq(:readiness_observed)
|
|
35
|
+
expect(revision_of(key_a)).to eq(1)
|
|
36
|
+
expect(state_of(key_a)).to eq(:available)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe 'exact-instance isolation' do
|
|
41
|
+
it 'keeps two instances of the same provider and model independent through failure and recovery' do
|
|
42
|
+
token_a = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
43
|
+
claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
|
|
44
|
+
|
|
45
|
+
described_class.readiness_failed(instance_key: key_a, probe_token: probe(token_a, key_a), reason: 'a failed')
|
|
46
|
+
expect(state_of(key_a)).to eq(:unavailable)
|
|
47
|
+
expect(state_of(key_b)).to eq(:available)
|
|
48
|
+
|
|
49
|
+
recover = described_class.readiness_succeeded(instance_key: key_a, probe_token: probe(token_a, key_a))
|
|
50
|
+
expect(recover.reason).to eq(:instance_recovered)
|
|
51
|
+
expect(state_of(key_a)).to eq(:available)
|
|
52
|
+
expect(state_of(key_b)).to eq(:available)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe 'availability revisions' do
|
|
57
|
+
it 'increments the availability revision on each successive unavailable transition' do
|
|
58
|
+
token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
59
|
+
described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'first')
|
|
60
|
+
first = revision_of(key_a)
|
|
61
|
+
described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'second')
|
|
62
|
+
expect(revision_of(key_a)).to eq(first + 1)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe 'stale and superseded probes cannot recover' do
|
|
67
|
+
it 'refuses recovery from a probe started before the failure' do
|
|
68
|
+
token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
69
|
+
stale = probe(token, key_a) # started at availability revision 1, before the failure
|
|
70
|
+
described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'failed')
|
|
71
|
+
result = described_class.readiness_succeeded(instance_key: key_a, probe_token: stale)
|
|
72
|
+
expect(result.applied).to be(false)
|
|
73
|
+
expect(result.reason).to eq(:stale_probe)
|
|
74
|
+
expect(state_of(key_a)).to eq(:unavailable)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'recovers with a probe started after the failure' do
|
|
78
|
+
token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
79
|
+
described_class.readiness_failed(instance_key: key_a, probe_token: probe(token, key_a), reason: 'failed')
|
|
80
|
+
fresh = probe(token, key_a) # started at the new (unavailable) revision
|
|
81
|
+
result = described_class.readiness_succeeded(instance_key: key_a, probe_token: fresh)
|
|
82
|
+
expect(result.reason).to eq(:instance_recovered)
|
|
83
|
+
expect(state_of(key_a)).to eq(:available)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe 'dispatch-reported instance unavailable' do
|
|
88
|
+
it 'marks only the exact active instance unavailable using the non-secret token id and enqueues a probe' do
|
|
89
|
+
enqueued = []
|
|
90
|
+
coordinator_a = probe_coordinator(key_a, enqueue: lambda { |request:|
|
|
91
|
+
enqueued << request
|
|
92
|
+
true
|
|
93
|
+
})
|
|
94
|
+
token_a = claim_and_activate(key: key_a, callable: fake_callable, coordinator: coordinator_a)
|
|
95
|
+
claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
|
|
96
|
+
|
|
97
|
+
result = described_class.dispatch_instance_unavailable(
|
|
98
|
+
instance_key: key_a, publisher_token_id: token_a.publisher_token_id, reason: 'normalized instance_unavailable'
|
|
99
|
+
)
|
|
100
|
+
expect(result.applied).to be(true)
|
|
101
|
+
expect(result.reason).to eq(:instance_unavailable)
|
|
102
|
+
expect(state_of(key_a)).to eq(:unavailable)
|
|
103
|
+
expect(state_of(key_b)).to eq(:available)
|
|
104
|
+
expect(enqueued.size).to eq(1)
|
|
105
|
+
expect(enqueued.first.instance_key).to eq(key_a)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'rejects a stale (non-current) publisher token id without mutating' do
|
|
109
|
+
claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
110
|
+
result = described_class.dispatch_instance_unavailable(
|
|
111
|
+
instance_key: key_a, publisher_token_id: 'ptok:v1:not-current', reason: 'x'
|
|
112
|
+
)
|
|
113
|
+
expect(result.applied).to be(false)
|
|
114
|
+
expect(result.reason).to eq(:stale_publisher)
|
|
115
|
+
expect(state_of(key_a)).to eq(:available)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'is a process-local method that accepts the public token id, not the secret PublisherToken' do
|
|
119
|
+
token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
120
|
+
# Passing the secret token object where a String id is expected must not authorize the transition.
|
|
121
|
+
result = described_class.dispatch_instance_unavailable(
|
|
122
|
+
instance_key: key_a, publisher_token_id: token, reason: 'x'
|
|
123
|
+
)
|
|
124
|
+
expect(result.applied).to be(false)
|
|
125
|
+
expect(state_of(key_a)).to eq(:available)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'is invalid for an initializing instance' do
|
|
129
|
+
token = described_class.claim_instance(instance_key: key_a, callable: fake_callable, probe_request_handle: probe_coordinator(key_a))
|
|
130
|
+
expect do
|
|
131
|
+
described_class.dispatch_instance_unavailable(instance_key: key_a, publisher_token_id: token.publisher_token_id, reason: 'x')
|
|
132
|
+
end.to raise_error(errors::InvalidTransitionError)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
describe 'global instance-unavailable filters all of that instance lanes' do
|
|
137
|
+
it 'marks every lane of one instance unavailable without touching another instance' do
|
|
138
|
+
two_models = drafts(native: 'gemma4', model: 'gemma4') +
|
|
139
|
+
drafts(native: 'gemma5', model: 'gemma5')
|
|
140
|
+
token_a = described_class.claim_instance(instance_key: key_a, callable: fake_callable, probe_request_handle: probe_coordinator(key_a))
|
|
141
|
+
probe_a = described_class.readiness_probe_started(instance_key: key_a, publisher_token: token_a)
|
|
142
|
+
described_class.activate_instance_snapshot(publisher_token: token_a, instance_key: key_a, offerings: two_models, sequence: 0, probe_token: probe_a)
|
|
143
|
+
claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
|
|
144
|
+
|
|
145
|
+
described_class.dispatch_instance_unavailable(instance_key: key_a, publisher_token_id: token_a.publisher_token_id, reason: 'down')
|
|
146
|
+
expect(state_of(key_a)).to eq(:unavailable)
|
|
147
|
+
# lanes remain visible for diagnosis on the instance record
|
|
148
|
+
expect(described_class.snapshot.lanes_for(instance_key: key_a).size).to eq(2)
|
|
149
|
+
expect(state_of(key_b)).to eq(:available)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
5
|
+
require_relative '../../../../support/ssot_registry_helpers'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
8
|
+
include SsotRegistryHelpers
|
|
9
|
+
|
|
10
|
+
errors = Legion::Extensions::Llm::Inventory::Errors
|
|
11
|
+
|
|
12
|
+
before { described_class.reset! }
|
|
13
|
+
|
|
14
|
+
let(:key) { instance_key }
|
|
15
|
+
let(:callable) { fake_callable }
|
|
16
|
+
let(:coordinator) { probe_coordinator(key) }
|
|
17
|
+
let(:token) { claim_and_activate(key: key, callable: callable, coordinator: coordinator) }
|
|
18
|
+
let(:handle) { token && described_class.snapshot.instance(instance_key: key).callable_handle }
|
|
19
|
+
|
|
20
|
+
it 'acquires a lease that exposes the exact captured callable' do
|
|
21
|
+
lease = described_class.acquire(callable_handle: handle)
|
|
22
|
+
expect(lease.callable).to equal(callable)
|
|
23
|
+
lease.release
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'rejects acquiring a non-CallableHandle' do
|
|
27
|
+
token
|
|
28
|
+
expect { described_class.acquire(callable_handle: Object.new) }.to raise_error(errors::UnknownCallableError)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'lets an already-acquired lease finish after the instance is removed, disconnecting exactly once' do
|
|
32
|
+
lease = described_class.acquire(callable_handle: handle)
|
|
33
|
+
described_class.remove_instance(instance_key: key, publisher_token: token)
|
|
34
|
+
expect(handle.state).to eq(:retiring)
|
|
35
|
+
expect(callable.disconnect_count).to eq(0)
|
|
36
|
+
# the in-flight lease still works
|
|
37
|
+
expect(lease.callable).to equal(callable)
|
|
38
|
+
lease.release
|
|
39
|
+
expect(handle.state).to eq(:disposed)
|
|
40
|
+
expect(callable.disconnect_count).to eq(1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'blocks a new acquisition against a retired handle' do
|
|
44
|
+
described_class.acquire(callable_handle: handle) # keep one lease so removal leaves it retiring
|
|
45
|
+
described_class.remove_instance(instance_key: key, publisher_token: token)
|
|
46
|
+
expect { described_class.acquire(callable_handle: handle) }.to raise_error(errors::StaleCallableError)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'removes an idle instance and disposes its handle' do
|
|
50
|
+
handle
|
|
51
|
+
described_class.remove_instance(instance_key: key, publisher_token: token)
|
|
52
|
+
expect(handle.state).to eq(:disposed)
|
|
53
|
+
expect(described_class.snapshot.instance(instance_key: key)).to be_nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'returns already_removed for a second removal' do
|
|
57
|
+
handle
|
|
58
|
+
described_class.remove_instance(instance_key: key, publisher_token: token)
|
|
59
|
+
result = described_class.remove_instance(instance_key: key, publisher_token: token)
|
|
60
|
+
expect(result.applied).to be(false)
|
|
61
|
+
expect(result.reason).to eq(:already_removed)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
5
|
+
require_relative '../../../../support/ssot_registry_helpers'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
8
|
+
include SsotRegistryHelpers
|
|
9
|
+
|
|
10
|
+
errors = Legion::Extensions::Llm::Inventory::Errors
|
|
11
|
+
|
|
12
|
+
before { described_class.reset! }
|
|
13
|
+
|
|
14
|
+
let(:key) { instance_key }
|
|
15
|
+
let(:callable) { fake_callable }
|
|
16
|
+
let(:coordinator) { probe_coordinator(key) }
|
|
17
|
+
let(:token) { claim_and_activate(key: key, callable: callable, coordinator: coordinator, sequence: 0) }
|
|
18
|
+
|
|
19
|
+
it 'replaces the complete offering set with a strictly increasing sequence' do
|
|
20
|
+
token
|
|
21
|
+
result = described_class.replace_instance_snapshot(
|
|
22
|
+
publisher_token: token, instance_key: key, offerings: drafts(native: 'gemma5', model: 'gemma5'), sequence: 1
|
|
23
|
+
)
|
|
24
|
+
expect(result.applied).to be(true)
|
|
25
|
+
expect(result.reason).to eq(:snapshot_replaced)
|
|
26
|
+
offerings = described_class.snapshot.offerings_for(instance_key: key)
|
|
27
|
+
expect(offerings.map(&:provider_native_key)).to eq(['gemma5'])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'preserves the exact availability fact across replacement' do
|
|
31
|
+
token
|
|
32
|
+
before_fact = described_class.snapshot.instance(instance_key: key).availability
|
|
33
|
+
described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 1)
|
|
34
|
+
after_fact = described_class.snapshot.instance(instance_key: key).availability
|
|
35
|
+
expect(after_fact.availability_revision).to eq(before_fact.availability_revision)
|
|
36
|
+
expect(after_fact.state).to eq(:available)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'rejects a non-increasing sequence' do
|
|
40
|
+
token
|
|
41
|
+
expect { described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 0) }
|
|
42
|
+
.to raise_error(errors::StaleSequenceError)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'treats an empty array as an authoritative complete empty snapshot' do
|
|
46
|
+
token
|
|
47
|
+
described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: [], sequence: 1)
|
|
48
|
+
expect(described_class.snapshot.offerings_for(instance_key: key)).to be_empty
|
|
49
|
+
expect(described_class.snapshot.instance(instance_key: key).availability.state).to eq(:available)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'leaves the prior root untouched when draft validation fails' do
|
|
53
|
+
token
|
|
54
|
+
generation = described_class.snapshot.generation
|
|
55
|
+
duplicate = drafts + drafts # two drafts with the same provider_native_key
|
|
56
|
+
expect { described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: duplicate, sequence: 1) }
|
|
57
|
+
.to raise_error(errors::ValidationError)
|
|
58
|
+
expect(described_class.snapshot.generation).to eq(generation)
|
|
59
|
+
expect(described_class.snapshot.offerings_for(instance_key: key).size).to eq(1)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'returns a stale publisher result for a superseded token' do
|
|
63
|
+
token
|
|
64
|
+
described_class.claim_instance(instance_key: key, callable: fake_callable, probe_request_handle: coordinator)
|
|
65
|
+
result = described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: drafts, sequence: 5)
|
|
66
|
+
expect(result.applied).to be(false)
|
|
67
|
+
expect(result.reason).to eq(:stale_publisher)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
require 'legion/extensions/llm/inventory/scoped_refresher'
|
|
5
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
6
|
+
require_relative '../../../../support/ssot_registry_helpers'
|
|
5
7
|
|
|
6
8
|
RSpec.describe Legion::Extensions::Llm::Inventory::ScopedRefresher do
|
|
7
9
|
describe '.compose_id' do
|
|
@@ -206,4 +208,130 @@ RSpec.describe Legion::Extensions::Llm::Inventory::ScopedRefresher do
|
|
|
206
208
|
expect(cache_store).not_to have_key(cooldown_key)
|
|
207
209
|
end
|
|
208
210
|
end
|
|
211
|
+
|
|
212
|
+
describe Legion::Extensions::Llm::Inventory::ScopedRefresher::LegacyCoordinatorAdapter do
|
|
213
|
+
include SsotRegistryHelpers
|
|
214
|
+
|
|
215
|
+
inventory = Legion::Extensions::Llm::Inventory
|
|
216
|
+
errors = inventory::Errors
|
|
217
|
+
|
|
218
|
+
let(:fake_inventory) do
|
|
219
|
+
Class.new do
|
|
220
|
+
attr_reader :lanes
|
|
221
|
+
|
|
222
|
+
def initialize
|
|
223
|
+
@lanes = {}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def write_lane(lane:, **)
|
|
227
|
+
@lanes[lane[:id]] = lane
|
|
228
|
+
lane
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def delete_lane(id:, **)
|
|
232
|
+
@lanes.delete(id)
|
|
233
|
+
:deleted
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def lanes_for(provider:, instance:, **)
|
|
237
|
+
@lanes.values.select { |l| l[:provider_family].to_s == provider.to_s && l[:instance_id].to_s == instance.to_s }
|
|
238
|
+
end
|
|
239
|
+
end.new
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
let(:key) { instance_key(family: 'vllm', instance: 'h200') }
|
|
243
|
+
let(:adapter) { described_class.new(provider_family: :vllm) }
|
|
244
|
+
|
|
245
|
+
before do
|
|
246
|
+
inventory::Registry.reset!
|
|
247
|
+
stub_const('Legion::LLM::Inventory', fake_inventory)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def activate_chat
|
|
251
|
+
claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
|
|
252
|
+
Legion::Extensions::Llm::Inventory::Registry.snapshot
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it 'projects an available instance into an exact five-field legacy lane and round-trips' do
|
|
256
|
+
result = adapter.sync_snapshot(snapshot: activate_chat, instance_key: key, mutation_reason: :activated)
|
|
257
|
+
expect(result).to eq(:applied)
|
|
258
|
+
|
|
259
|
+
legacy_id = 'local:vllm:h200:inference:gemma4'
|
|
260
|
+
expect(fake_inventory.lanes).to have_key(legacy_id)
|
|
261
|
+
lane = fake_inventory.lanes[legacy_id]
|
|
262
|
+
expect(lane[:id]).to eq(legacy_id)
|
|
263
|
+
expect(lane[:provider_instance]).to eq('h200')
|
|
264
|
+
expect(lane[:enabled]).to be(true)
|
|
265
|
+
expect(lane[:capabilities]).to include(:completion)
|
|
266
|
+
expect(lane[:metadata]).to include(ssot_v3_compatibility_projection: true)
|
|
267
|
+
round_trip = fake_inventory.lanes_for(provider: :vllm, instance: 'h200', type: :inference, model: 'gemma4')
|
|
268
|
+
expect(round_trip.map { |l| l[:id] }).to eq([legacy_id])
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
it 'deletes the old projection when the instance is removed (desired set empty)' do
|
|
272
|
+
token = claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
|
|
273
|
+
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
|
|
274
|
+
expect(fake_inventory.lanes).not_to be_empty
|
|
275
|
+
|
|
276
|
+
inventory::Registry.remove_instance(instance_key: key, publisher_token: token)
|
|
277
|
+
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :removed)
|
|
278
|
+
expect(fake_inventory.lanes).to be_empty
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'deletes the old projection when the instance becomes unavailable' do
|
|
282
|
+
token = claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
|
|
283
|
+
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
|
|
284
|
+
inventory::Registry.dispatch_instance_unavailable(instance_key: key, publisher_token_id: token.publisher_token_id, reason: 'down')
|
|
285
|
+
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :instance_unavailable)
|
|
286
|
+
expect(fake_inventory.lanes).to be_empty
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it 'fails closed and omits an ambiguous group that collapses multiple offerings' do
|
|
290
|
+
token = inventory::Registry.claim_instance(instance_key: key, callable: fake_callable, probe_request_handle: probe_coordinator(key))
|
|
291
|
+
probe = inventory::Registry.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
292
|
+
two = drafts(native: 'a', model: 'gemma4') + drafts(native: 'b', model: 'gemma4')
|
|
293
|
+
inventory::Registry.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: two, sequence: 0, probe_token: probe)
|
|
294
|
+
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
|
|
295
|
+
expect(fake_inventory.lanes).to be_empty
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it 'returns :not_loaded when Legion::LLM::Inventory is absent' do
|
|
299
|
+
hide_const('Legion::LLM::Inventory')
|
|
300
|
+
expect(adapter.sync_snapshot(snapshot: activate_chat, instance_key: key, mutation_reason: :activated)).to eq(:not_loaded)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
it 'rejects a different provider family, an invalid snapshot, and an unknown reason' do
|
|
304
|
+
snapshot = activate_chat
|
|
305
|
+
other = instance_key(family: 'openai', instance: 'h200')
|
|
306
|
+
expect { adapter.sync_snapshot(snapshot: snapshot, instance_key: other, mutation_reason: :activated) }
|
|
307
|
+
.to raise_error(errors::ValidationError)
|
|
308
|
+
expect { adapter.sync_snapshot(snapshot: {}, instance_key: key, mutation_reason: :activated) }
|
|
309
|
+
.to raise_error(errors::ValidationError)
|
|
310
|
+
expect { adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :made_up) }
|
|
311
|
+
.to raise_error(errors::ValidationError)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it 'returns :failed and preserves the prior tracked set when a write raises' do
|
|
315
|
+
snapshot = activate_chat
|
|
316
|
+
allow(fake_inventory).to receive(:write_lane).and_raise(StandardError, 'coordinator down')
|
|
317
|
+
expect(adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :activated)).to eq(:failed)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
it 'never lets an old five-part id be accepted as a canonical lane:v1: identity' do
|
|
321
|
+
offering_id = inventory::Identity.offering_id(instance_key: key, provider_native_key: 'gemma4')
|
|
322
|
+
expect do
|
|
323
|
+
inventory::Identity.validate_lane_id!(
|
|
324
|
+
value: 'local:vllm:h200:inference:gemma4', instance_key: key,
|
|
325
|
+
operation: :chat, model: 'gemma4', offering_id: offering_id
|
|
326
|
+
)
|
|
327
|
+
end.to raise_error(errors::ValidationError)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it 'leaves the new Registry unchanged when the legacy projection runs' do
|
|
331
|
+
snapshot = activate_chat
|
|
332
|
+
generation = inventory::Registry.snapshot.generation
|
|
333
|
+
adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :activated)
|
|
334
|
+
expect(inventory::Registry.snapshot.generation).to eq(generation)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
209
337
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
5
|
+
require_relative '../../../../support/ssot_registry_helpers'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
|
|
8
|
+
include SsotRegistryHelpers
|
|
9
|
+
|
|
10
|
+
registry = Legion::Extensions::Llm::Inventory::Registry
|
|
11
|
+
|
|
12
|
+
before { registry.reset! }
|
|
13
|
+
|
|
14
|
+
let(:key_a) { instance_key(instance: 'h200') }
|
|
15
|
+
let(:key_b) { instance_key(instance: 'helios1') }
|
|
16
|
+
|
|
17
|
+
def activate_both
|
|
18
|
+
claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
19
|
+
claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
|
|
20
|
+
registry.snapshot
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe 'lookups' do
|
|
24
|
+
it 'returns the frozen record or nil and never synthesizes a default' do
|
|
25
|
+
snapshot = activate_both
|
|
26
|
+
record = snapshot.instance(instance_key: key_a)
|
|
27
|
+
expect(record).to be_frozen
|
|
28
|
+
expect(snapshot.instance(instance_key: instance_key(instance: 'absent'))).to be_nil
|
|
29
|
+
expect(snapshot.lane(lane_id: 'lane:v1:missing')).to be_nil
|
|
30
|
+
expect(snapshot.offering(offering_id: 'off:v1:missing')).to be_nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'only lists activated instances but keeps initializing claims in publication status' do
|
|
34
|
+
claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
35
|
+
registry.claim_instance(instance_key: key_b, callable: fake_callable, probe_request_handle: probe_coordinator(key_b))
|
|
36
|
+
snapshot = registry.snapshot
|
|
37
|
+
expect(snapshot.instance(instance_key: key_a)).not_to be_nil
|
|
38
|
+
expect(snapshot.instance(instance_key: key_b)).to be_nil
|
|
39
|
+
expect(snapshot.publication_status(instance_key: key_b).state).to eq(:initializing)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe 'ordering and iteration' do
|
|
44
|
+
it 'returns frozen lanes_for/offerings_for arrays ordered by id' do
|
|
45
|
+
snapshot = activate_both
|
|
46
|
+
lanes = snapshot.lanes_for(instance_key: key_a)
|
|
47
|
+
expect(lanes).to be_frozen
|
|
48
|
+
expect(lanes.map(&:lane_id)).to eq(lanes.map(&:lane_id).sort)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'each_lane/each_offering yield in id order and return an Enumerator without a block' do
|
|
52
|
+
snapshot = activate_both
|
|
53
|
+
expect(snapshot.each_lane).to be_a(Enumerator)
|
|
54
|
+
expect(snapshot.each_offering).to be_a(Enumerator)
|
|
55
|
+
expect(snapshot.each_lane.map(&:lane_id)).to eq(snapshot.each_lane.map(&:lane_id).sort)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe 'each_instance and each_publication_status' do
|
|
60
|
+
it 'return an Enumerator without a block' do
|
|
61
|
+
snapshot = activate_both
|
|
62
|
+
expect(snapshot.each_instance).to be_a(Enumerator)
|
|
63
|
+
expect(snapshot.each_publication_status).to be_a(Enumerator)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'yield immutable instance records in canonical InstanceKey order' do
|
|
67
|
+
snapshot = activate_both
|
|
68
|
+
records = snapshot.each_instance.to_a
|
|
69
|
+
expect(records.map { |record| record.instance_key.instance_id }).to eq(%w[h200 helios1])
|
|
70
|
+
expect(records).to all(be_a(Legion::Extensions::Llm::Inventory::InstanceRecord))
|
|
71
|
+
expect(records).to all(be_frozen)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'include initializing claims in each_publication_status but not each_instance' do
|
|
75
|
+
claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
76
|
+
registry.claim_instance(instance_key: key_b, callable: fake_callable, probe_request_handle: probe_coordinator(key_b))
|
|
77
|
+
snapshot = registry.snapshot
|
|
78
|
+
status_states = snapshot.each_publication_status.map(&:state)
|
|
79
|
+
expect(status_states).to contain_exactly(:complete, :initializing)
|
|
80
|
+
expect(snapshot.each_instance.map(&:instance_key)).to eq([key_a])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'does not expose either backing Hash' do
|
|
84
|
+
snapshot = activate_both
|
|
85
|
+
expect(snapshot).not_to respond_to(:instances_by_key)
|
|
86
|
+
expect(snapshot).not_to respond_to(:publication_status_by_key)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe 'structural immutability of an older snapshot' do
|
|
91
|
+
it 'does not observe a later record or generation mutation' do
|
|
92
|
+
claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
93
|
+
old = registry.snapshot
|
|
94
|
+
old_generation = old.generation
|
|
95
|
+
claim_and_activate(key: key_b, callable: fake_callable, coordinator: probe_coordinator(key_b))
|
|
96
|
+
expect(old.generation).to eq(old_generation)
|
|
97
|
+
expect(old.instance(instance_key: key_b)).to be_nil
|
|
98
|
+
expect(registry.snapshot.instance(instance_key: key_b)).not_to be_nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'lets a captured callable handle move to retiring after fencing (lifecycle exception)' do
|
|
102
|
+
token = claim_and_activate(key: key_a, callable: fake_callable, coordinator: probe_coordinator(key_a))
|
|
103
|
+
captured = registry.snapshot.instance(instance_key: key_a).callable_handle
|
|
104
|
+
expect(captured.state).to eq(:active)
|
|
105
|
+
registry.remove_instance(instance_key: key_a, publisher_token: token)
|
|
106
|
+
expect(captured.state).to eq(:disposed)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|