detached 31711a6

fix(broker): read snapshots passively so vev ls never triggers a probe

commit
31711a6963f2ba46fee5c93b9adf1ab7077f4f6e
author
brice · · 2026-09-23 11:44 UTC
parent
5a1c47b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +173 -21
M internal/adapters/brokeripc/client.go +1 -0
@@ -503,6 +503,7 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) {
503503 c.mu.Unlock()
504504 if err := c.sendAsync(brokerwire.Subscribe{
505505 Epoch: c.scope.Epoch, Connection: c.scope.Connection, Generation: generation,
506+ Passive: c.cfg.PassiveSubscribe,
506507 }); err != nil {
507508 // The generation was already consumed by the connection's subscription
508509 // tracker, so the previous subscription's generation is no longer
@@ -503,6 +503,7 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) {
503 c.mu.Unlock()503 c.mu.Unlock()
504 if err := c.sendAsync(brokerwire.Subscribe{504 if err := c.sendAsync(brokerwire.Subscribe{
505 Epoch: c.scope.Epoch, Connection: c.scope.Connection, Generation: generation,505 Epoch: c.scope.Epoch, Connection: c.scope.Connection, Generation: generation,
506+ Passive: c.cfg.PassiveSubscribe,
506 }); err != nil {507 }); err != nil {
507 // The generation was already consumed by the connection's subscription508 // The generation was already consumed by the connection's subscription
508 // tracker, so the previous subscription's generation is no longer509 // tracker, so the previous subscription's generation is no longer
M internal/adapters/brokeripc/config.go +4 -0
@@ -46,6 +46,10 @@ type Config struct {
4646 // MaxPendingOperations bounds mutating operations this side is waiting for
4747 // on one connection. It never exceeds the brokerwire per-connection bound.
4848 MaxPendingOperations int
49+ // PassiveSubscribe marks every snapshot subscription of a client
50+ // connection as a read-only reader: it receives publications but never
51+ // counts as observation demand, so a CLI read never triggers a probe.
52+ PassiveSubscribe bool
4953 }
5054
5155 // withDefaults fills every unset bound with its package default.
@@ -46,6 +46,10 @@ type Config struct {
46 // MaxPendingOperations bounds mutating operations this side is waiting for46 // MaxPendingOperations bounds mutating operations this side is waiting for
47 // on one connection. It never exceeds the brokerwire per-connection bound.47 // on one connection. It never exceeds the brokerwire per-connection bound.
48 MaxPendingOperations int48 MaxPendingOperations int
49+ // PassiveSubscribe marks every snapshot subscription of a client
50+ // connection as a read-only reader: it receives publications but never
51+ // counts as observation demand, so a CLI read never triggers a probe.
52+ PassiveSubscribe bool
49 }53 }
50 54
51 // withDefaults fills every unset bound with its package default.55 // withDefaults fills every unset bound with its package default.
M internal/adapters/brokeripc/helpers_test.go +27 -4
@@ -396,9 +396,11 @@ type fakeCore struct {
396396 updated []updateHostPolicyCall
397397 reconcile []string
398398 openErr error
399- closed bool
400- done chan struct{}
401- once sync.Once
399+ // subscribes records each core subscription's reader kind (true = passive).
400+ subscribes []bool
401+ closed bool
402+ done chan struct{}
403+ once sync.Once
402404
403405 // Membership result hooks. Their zero values keep the deterministic
404406 // defaults that mirror the real registry: a fresh generation-1 registration
@@ -431,7 +433,28 @@ func (c *fakeCore) Err() error { return nil }
431433
432434 func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() }
433435
434-func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil }
436+func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) {
437+ return c.recordSubscribe(false), nil
438+}
439+
440+// SubscribePassive is the optional passive-reader capability the server uses
441+// for a Subscribe flagged passive.
442+func (c *fakeCore) SubscribePassive() (ports.BrokerSubscription, error) {
443+ return c.recordSubscribe(true), nil
444+}
445+
446+func (c *fakeCore) recordSubscribe(passive bool) *fakeSubscription {
447+ c.mu.Lock()
448+ c.subscribes = append(c.subscribes, passive)
449+ c.mu.Unlock()
450+ return c.hub.subscribe()
451+}
452+
453+func (c *fakeCore) subscribeKinds() []bool {
454+ c.mu.Lock()
455+ defer c.mu.Unlock()
456+ return append([]bool(nil), c.subscribes...)
457+}
435458
436459 // fakePreviewSubscription is one server-side preview subscription whose
437460 // Changed channel a test drives explicitly.
@@ -396,9 +396,11 @@ type fakeCore struct {
396 updated []updateHostPolicyCall396 updated []updateHostPolicyCall
397 reconcile []string397 reconcile []string
398 openErr error398 openErr error
399- closed bool399+ // subscribes records each core subscription's reader kind (true = passive).
400- done chan struct{}400+ subscribes []bool
401- once sync.Once401+ closed bool
402+ done chan struct{}
403+ once sync.Once
402 404
403 // Membership result hooks. Their zero values keep the deterministic405 // Membership result hooks. Their zero values keep the deterministic
404 // defaults that mirror the real registry: a fresh generation-1 registration406 // defaults that mirror the real registry: a fresh generation-1 registration
@@ -431,7 +433,28 @@ func (c *fakeCore) Err() error { return nil }
431 433
432 func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() }434 func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() }
433 435
434-func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil }436+func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) {
437+ return c.recordSubscribe(false), nil
438+}
439+
440+// SubscribePassive is the optional passive-reader capability the server uses
441+// for a Subscribe flagged passive.
442+func (c *fakeCore) SubscribePassive() (ports.BrokerSubscription, error) {
443+ return c.recordSubscribe(true), nil
444+}
445+
446+func (c *fakeCore) recordSubscribe(passive bool) *fakeSubscription {
447+ c.mu.Lock()
448+ c.subscribes = append(c.subscribes, passive)
449+ c.mu.Unlock()
450+ return c.hub.subscribe()
451+}
452+
453+func (c *fakeCore) subscribeKinds() []bool {
454+ c.mu.Lock()
455+ defer c.mu.Unlock()
456+ return append([]bool(nil), c.subscribes...)
457+}
435 458
436 // fakePreviewSubscription is one server-side preview subscription whose459 // fakePreviewSubscription is one server-side preview subscription whose
437 // Changed channel a test drives explicitly.460 // Changed channel a test drives explicitly.
M internal/adapters/brokeripc/mutation.go +13 -2
@@ -38,12 +38,23 @@ type publisher struct {
3838 wake chan struct{}
3939 }
4040
41+// passiveSubscriber is the optional core capability behind a passive
42+// Subscribe: a read-only snapshot subscription that never counts as
43+// observation demand. A core without it serves every subscription as demand.
44+type passiveSubscriber interface {
45+ SubscribePassive() (ports.BrokerSubscription, error)
46+}
47+
4148 // retargetPublisher replaces any active publisher with one for the new
4249 // generation and publishes the current snapshot immediately. A failed
4350 // subscription ends the superseded series first, so no publisher survives to
4451 // publish under a generation the connection tracker already retired.
45-func (s *serverSession) retargetPublisher(generation brokerwire.SubscriptionGeneration) error {
46- sub, err := s.core.Subscribe()
52+func (s *serverSession) retargetPublisher(generation brokerwire.SubscriptionGeneration, passive bool) error {
53+ subscribe := s.core.Subscribe
54+ if reader, ok := s.core.(passiveSubscriber); passive && ok {
55+ subscribe = reader.SubscribePassive
56+ }
57+ sub, err := subscribe()
4758 if err != nil {
4859 s.stopPublisher()
4960 detail := errorDetail(err)
@@ -38,12 +38,23 @@ type publisher struct {
38 wake chan struct{}38 wake chan struct{}
39 }39 }
40 40
41+// passiveSubscriber is the optional core capability behind a passive
42+// Subscribe: a read-only snapshot subscription that never counts as
43+// observation demand. A core without it serves every subscription as demand.
44+type passiveSubscriber interface {
45+ SubscribePassive() (ports.BrokerSubscription, error)
46+}
47+
41 // retargetPublisher replaces any active publisher with one for the new48 // retargetPublisher replaces any active publisher with one for the new
42 // generation and publishes the current snapshot immediately. A failed49 // generation and publishes the current snapshot immediately. A failed
43 // subscription ends the superseded series first, so no publisher survives to50 // subscription ends the superseded series first, so no publisher survives to
44 // publish under a generation the connection tracker already retired.51 // publish under a generation the connection tracker already retired.
45-func (s *serverSession) retargetPublisher(generation brokerwire.SubscriptionGeneration) error {52+func (s *serverSession) retargetPublisher(generation brokerwire.SubscriptionGeneration, passive bool) error {
46- sub, err := s.core.Subscribe()53+ subscribe := s.core.Subscribe
54+ if reader, ok := s.core.(passiveSubscriber); passive && ok {
55+ subscribe = reader.SubscribePassive
56+ }
57+ sub, err := subscribe()
47 if err != nil {58 if err != nil {
48 s.stopPublisher()59 s.stopPublisher()
49 detail := errorDetail(err)60 detail := errorDetail(err)
M internal/adapters/brokeripc/server.go +1 -1
@@ -664,7 +664,7 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error {
664664 if err := s.conn.Subscribe(m.Generation); err != nil {
665665 return errors.Join(ErrProtocol, err)
666666 }
667- return s.retargetPublisher(m.Generation)
667+ return s.retargetPublisher(m.Generation, m.Passive)
668668 case brokerwire.Resync:
669669 if !s.scopeMatches(m.Epoch, m.Connection) {
670670 return s.refuseScope()
@@ -664,7 +664,7 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error {
664 if err := s.conn.Subscribe(m.Generation); err != nil {664 if err := s.conn.Subscribe(m.Generation); err != nil {
665 return errors.Join(ErrProtocol, err)665 return errors.Join(ErrProtocol, err)
666 }666 }
667- return s.retargetPublisher(m.Generation)667+ return s.retargetPublisher(m.Generation, m.Passive)
668 case brokerwire.Resync:668 case brokerwire.Resync:
669 if !s.scopeMatches(m.Epoch, m.Connection) {669 if !s.scopeMatches(m.Epoch, m.Connection) {
670 return s.refuseScope()670 return s.refuseScope()
M internal/adapters/brokeripc/session_test.go +29 -3
@@ -39,7 +39,7 @@ func TestResubscribePublishesCurrentSnapshotPerGeneration(t *testing.T) {
3939 generation := brokerwire.SubscriptionGeneration(index)
4040 core.hub.publish(testSnapshot(epoch, ports.BrokerRevision(index)))
4141 require.NoError(t, session.conn.Subscribe(generation))
42- require.NoError(t, session.retargetPublisher(generation))
42+ require.NoError(t, session.retargetPublisher(generation, false))
4343 }
4444
4545 // Each publisher owns its wake, so a later generation can never be left
@@ -79,14 +79,14 @@ func TestSupersededPublisherOwnsItsWake(t *testing.T) {
7979
8080 core.hub.publish(testSnapshot(epoch, 1))
8181 require.NoError(t, session.conn.Subscribe(1))
82- require.NoError(t, session.retargetPublisher(1))
82+ require.NoError(t, session.retargetPublisher(1, false))
8383 session.subMu.Lock()
8484 first := session.pub
8585 session.subMu.Unlock()
8686
8787 core.hub.publish(testSnapshot(epoch, 2))
8888 require.NoError(t, session.conn.Subscribe(2))
89- require.NoError(t, session.retargetPublisher(2))
89+ require.NoError(t, session.retargetPublisher(2, false))
9090 session.subMu.Lock()
9191 second := session.pub
9292 session.subMu.Unlock()
@@ -229,6 +229,32 @@ func TestSubscribePublishesSnapshot(t *testing.T) {
229229 }
230230 }
231231
232+// TestSubscribeCarriesPassiveReader proves a client configured as a passive
233+// reader reaches the core's passive subscription, while a default client
234+// subscribes as a watching client that records observation demand.
235+func TestSubscribeCarriesPassiveReader(t *testing.T) {
236+ tests := []struct {
237+ name string
238+ passive bool
239+ }{
240+ {name: "watching client", passive: false},
241+ {name: "passive reader", passive: true},
242+ }
243+ for _, tt := range tests {
244+ t.Run(tt.name, func(t *testing.T) {
245+ e := startEndpoint(t, Config{})
246+ client := e.dialWith(Config{PassiveSubscribe: tt.passive})
247+ e.accept()
248+ sub, err := client.Subscribe()
249+ require.NoError(t, err)
250+ t.Cleanup(sub.Close)
251+ core := e.authority.last()
252+ require.Eventually(t, func() bool { return len(core.subscribeKinds()) == 1 }, 5*time.Second, 10*time.Millisecond)
253+ require.Equal(t, []bool{tt.passive}, core.subscribeKinds())
254+ })
255+ }
256+}
257+
232258 // TestSubscribeGenerationAdvancesAndReplaces proves a second Subscribe advances
233259 // the generation series (refusing reuse) and replaces the previous
234260 // subscription.
@@ -39,7 +39,7 @@ func TestResubscribePublishesCurrentSnapshotPerGeneration(t *testing.T) {
39 generation := brokerwire.SubscriptionGeneration(index)39 generation := brokerwire.SubscriptionGeneration(index)
40 core.hub.publish(testSnapshot(epoch, ports.BrokerRevision(index)))40 core.hub.publish(testSnapshot(epoch, ports.BrokerRevision(index)))
41 require.NoError(t, session.conn.Subscribe(generation))41 require.NoError(t, session.conn.Subscribe(generation))
42- require.NoError(t, session.retargetPublisher(generation))42+ require.NoError(t, session.retargetPublisher(generation, false))
43 }43 }
44 44
45 // Each publisher owns its wake, so a later generation can never be left45 // Each publisher owns its wake, so a later generation can never be left
@@ -79,14 +79,14 @@ func TestSupersededPublisherOwnsItsWake(t *testing.T) {
79 79
80 core.hub.publish(testSnapshot(epoch, 1))80 core.hub.publish(testSnapshot(epoch, 1))
81 require.NoError(t, session.conn.Subscribe(1))81 require.NoError(t, session.conn.Subscribe(1))
82- require.NoError(t, session.retargetPublisher(1))82+ require.NoError(t, session.retargetPublisher(1, false))
83 session.subMu.Lock()83 session.subMu.Lock()
84 first := session.pub84 first := session.pub
85 session.subMu.Unlock()85 session.subMu.Unlock()
86 86
87 core.hub.publish(testSnapshot(epoch, 2))87 core.hub.publish(testSnapshot(epoch, 2))
88 require.NoError(t, session.conn.Subscribe(2))88 require.NoError(t, session.conn.Subscribe(2))
89- require.NoError(t, session.retargetPublisher(2))89+ require.NoError(t, session.retargetPublisher(2, false))
90 session.subMu.Lock()90 session.subMu.Lock()
91 second := session.pub91 second := session.pub
92 session.subMu.Unlock()92 session.subMu.Unlock()
@@ -229,6 +229,32 @@ func TestSubscribePublishesSnapshot(t *testing.T) {
229 }229 }
230 }230 }
231 231
232+// TestSubscribeCarriesPassiveReader proves a client configured as a passive
233+// reader reaches the core's passive subscription, while a default client
234+// subscribes as a watching client that records observation demand.
235+func TestSubscribeCarriesPassiveReader(t *testing.T) {
236+ tests := []struct {
237+ name string
238+ passive bool
239+ }{
240+ {name: "watching client", passive: false},
241+ {name: "passive reader", passive: true},
242+ }
243+ for _, tt := range tests {
244+ t.Run(tt.name, func(t *testing.T) {
245+ e := startEndpoint(t, Config{})
246+ client := e.dialWith(Config{PassiveSubscribe: tt.passive})
247+ e.accept()
248+ sub, err := client.Subscribe()
249+ require.NoError(t, err)
250+ t.Cleanup(sub.Close)
251+ core := e.authority.last()
252+ require.Eventually(t, func() bool { return len(core.subscribeKinds()) == 1 }, 5*time.Second, 10*time.Millisecond)
253+ require.Equal(t, []bool{tt.passive}, core.subscribeKinds())
254+ })
255+ }
256+}
257+
232 // TestSubscribeGenerationAdvancesAndReplaces proves a second Subscribe advances258 // TestSubscribeGenerationAdvancesAndReplaces proves a second Subscribe advances
233 // the generation series (refusing reuse) and replaces the previous259 // the generation series (refusing reuse) and replaces the previous
234 // subscription.260 // subscription.
M internal/adapters/brokerwire/codec.go +2 -2
@@ -116,7 +116,7 @@ func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.Br
116116 return nil, ErrInvalidMessage
117117 }
118118 return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Subscribe{Subscribe: &wire.Subscribe{
119- Scope: scopeToWire(m.Epoch, m.Connection), Generation: m.Generation,
119+ Scope: scopeToWire(m.Epoch, m.Connection), Generation: m.Generation, Passive: m.Passive,
120120 }}}, nil
121121 case *Subscribe:
122122 if m == nil {
@@ -491,7 +491,7 @@ func subscribeFromWire(message *wire.Subscribe) (Subscribe, error) {
491491 if err != nil {
492492 return Subscribe{}, ErrInvalidMessage
493493 }
494- return Subscribe{Epoch: epoch, Connection: connection, Generation: message.GetGeneration()}, nil
494+ return Subscribe{Epoch: epoch, Connection: connection, Generation: message.GetGeneration(), Passive: message.GetPassive()}, nil
495495 }
496496
497497 func resyncFromWire(message *wire.Resync) (Resync, error) {
@@ -116,7 +116,7 @@ func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.Br
116 return nil, ErrInvalidMessage116 return nil, ErrInvalidMessage
117 }117 }
118 return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Subscribe{Subscribe: &wire.Subscribe{118 return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Subscribe{Subscribe: &wire.Subscribe{
119- Scope: scopeToWire(m.Epoch, m.Connection), Generation: m.Generation,119+ Scope: scopeToWire(m.Epoch, m.Connection), Generation: m.Generation, Passive: m.Passive,
120 }}}, nil120 }}}, nil
121 case *Subscribe:121 case *Subscribe:
122 if m == nil {122 if m == nil {
@@ -491,7 +491,7 @@ func subscribeFromWire(message *wire.Subscribe) (Subscribe, error) {
491 if err != nil {491 if err != nil {
492 return Subscribe{}, ErrInvalidMessage492 return Subscribe{}, ErrInvalidMessage
493 }493 }
494- return Subscribe{Epoch: epoch, Connection: connection, Generation: message.GetGeneration()}, nil494+ return Subscribe{Epoch: epoch, Connection: connection, Generation: message.GetGeneration(), Passive: message.GetPassive()}, nil
495 }495 }
496 496
497 func resyncFromWire(message *wire.Resync) (Resync, error) {497 func resyncFromWire(message *wire.Resync) (Resync, error) {
M internal/adapters/brokerwire/codec_test.go +10 -0
@@ -803,6 +803,16 @@ func TestBrokerGoldenVectors(t *testing.T) {
803803 back, err := DecodeClient(rawSub, testEnvelopeCeiling, testChunkCeiling)
804804 require.NoError(t, err)
805805 require.Equal(t, sub, back)
806+
807+ // The passive reader flag survives the round trip, and an active
808+ // subscription encodes without it (proto3 omits false).
809+ passive := sub
810+ passive.Passive = true
811+ rawPassive := mustEncodeClient(t, passive)
812+ back, err = DecodeClient(rawPassive, testEnvelopeCeiling, testChunkCeiling)
813+ require.NoError(t, err)
814+ require.Equal(t, passive, back)
815+ require.Greater(t, len(rawPassive), len(rawSub))
806816 }
807817
808818 // TestBrokerBounds proves stateless bound refusals.
@@ -803,6 +803,16 @@ func TestBrokerGoldenVectors(t *testing.T) {
803 back, err := DecodeClient(rawSub, testEnvelopeCeiling, testChunkCeiling)803 back, err := DecodeClient(rawSub, testEnvelopeCeiling, testChunkCeiling)
804 require.NoError(t, err)804 require.NoError(t, err)
805 require.Equal(t, sub, back)805 require.Equal(t, sub, back)
806+
807+ // The passive reader flag survives the round trip, and an active
808+ // subscription encodes without it (proto3 omits false).
809+ passive := sub
810+ passive.Passive = true
811+ rawPassive := mustEncodeClient(t, passive)
812+ back, err = DecodeClient(rawPassive, testEnvelopeCeiling, testChunkCeiling)
813+ require.NoError(t, err)
814+ require.Equal(t, passive, back)
815+ require.Greater(t, len(rawPassive), len(rawSub))
806 }816 }
807 817
808 // TestBrokerBounds proves stateless bound refusals.818 // TestBrokerBounds proves stateless bound refusals.
M internal/adapters/brokerwire/messages.go +3 -0
@@ -44,6 +44,9 @@ type Subscribe struct {
4444 Epoch ports.BrokerEpoch
4545 Connection ports.BrokerConnectionID
4646 Generation SubscriptionGeneration
47+ // Passive marks a read-only snapshot reader that must never count as
48+ // observation demand.
49+ Passive bool
4750 }
4851
4952 func (Subscribe) brokerClientMessage() {}
@@ -44,6 +44,9 @@ type Subscribe struct {
44 Epoch ports.BrokerEpoch44 Epoch ports.BrokerEpoch
45 Connection ports.BrokerConnectionID45 Connection ports.BrokerConnectionID
46 Generation SubscriptionGeneration46 Generation SubscriptionGeneration
47+ // Passive marks a read-only snapshot reader that must never count as
48+ // observation demand.
49+ Passive bool
47 }50 }
48 51
49 func (Subscribe) brokerClientMessage() {}52 func (Subscribe) brokerClientMessage() {}
M internal/app/broker_production_connect.go +1 -1
@@ -16,7 +16,7 @@ const productionBrokerStartupTimeout = 10 * time.Second
1616 // connectExistingBroker dials without ensuring or spawning a broker.
1717 func connectExistingBroker(ctx context.Context) (ports.BrokerService, error) {
1818 path := brokeripc.SocketPath(productionBrokerLayout().Runtime)
19- service, err := brokeripc.NewConnector(path, brokeripc.Config{}).Connect(ctx)
19+ service, err := brokeripc.NewConnector(path, brokeripc.Config{PassiveSubscribe: true}).Connect(ctx)
2020 if err != nil {
2121 if backendAbsent(err) {
2222 return nil, absentError(path)
@@ -16,7 +16,7 @@ const productionBrokerStartupTimeout = 10 * time.Second
16 // connectExistingBroker dials without ensuring or spawning a broker.16 // connectExistingBroker dials without ensuring or spawning a broker.
17 func connectExistingBroker(ctx context.Context) (ports.BrokerService, error) {17 func connectExistingBroker(ctx context.Context) (ports.BrokerService, error) {
18 path := brokeripc.SocketPath(productionBrokerLayout().Runtime)18 path := brokeripc.SocketPath(productionBrokerLayout().Runtime)
19- service, err := brokeripc.NewConnector(path, brokeripc.Config{}).Connect(ctx)19+ service, err := brokeripc.NewConnector(path, brokeripc.Config{PassiveSubscribe: true}).Connect(ctx)
20 if err != nil {20 if err != nil {
21 if backendAbsent(err) {21 if backendAbsent(err) {
22 return nil, absentError(path)22 return nil, absentError(path)
M internal/protocol/wire/broker.pb.go +17 -5 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +4 -0
@@ -116,6 +116,10 @@ message Register {}
116116 message Subscribe {
117117 BrokerScope scope = 1;
118118 uint64 generation = 2;
119+ // passive marks a read-only snapshot reader (vev ls, vev host list). It
120+ // receives publications but never counts as observation demand, so reading
121+ // state never triggers a probe.
122+ bool passive = 3;
119123 }
120124
121125 // Resync asks for a full snapshot publication after an epoch change or a
@@ -116,6 +116,10 @@ message Register {}
116 message Subscribe {116 message Subscribe {
117 BrokerScope scope = 1;117 BrokerScope scope = 1;
118 uint64 generation = 2;118 uint64 generation = 2;
119+ // passive marks a read-only snapshot reader (vev ls, vev host list). It
120+ // receives publications but never counts as observation demand, so reading
121+ // state never triggers a probe.
122+ bool passive = 3;
119 }123 }
120 124
121 // Resync asks for a full snapshot publication after an epoch change or a125 // Resync asks for a full snapshot publication after an epoch change or a
M internal/usecase/broker/service.go +19 -3
@@ -230,14 +230,27 @@ func (s *Service) Snapshot() ports.BrokerSnapshot { return s.registry.Snapshot()
230230 // watches the snapshot, the registry observes on the demand cadence. Without
231231 // a subscriber it performs no scheduled observations. Close balances demand.
232232 func (s *Service) Subscribe() (ports.BrokerSubscription, error) {
233+ return s.subscribe(true)
234+}
235+
236+// SubscribePassive is Subscribe for a read-only snapshot reader (vev ls, vev
237+// host list): it receives every publication but records no demand, so reading
238+// state never triggers an observation.
239+func (s *Service) SubscribePassive() (ports.BrokerSubscription, error) {
240+ return s.subscribe(false)
241+}
242+
243+func (s *Service) subscribe(demand bool) (ports.BrokerSubscription, error) {
233244 s.mu.Lock()
234245 if s.closed {
235246 s.mu.Unlock()
236247 return nil, ports.BrokerAdmissionClosed
237248 }
238- sub := &serviceSubscription{service: s, inner: s.registry.Subscribe()}
249+ sub := &serviceSubscription{service: s, inner: s.registry.Subscribe(), demand: demand}
239250 s.subs[sub] = struct{}{}
240- s.registry.SetDemand(true)
251+ if demand {
252+ s.registry.SetDemand(true)
253+ }
241254 s.mu.Unlock()
242255 return sub, nil
243256 }
@@ -462,6 +475,7 @@ func (s *Service) openContext(ctx context.Context) (context.Context, func()) {
462475 type serviceSubscription struct {
463476 service *Service
464477 inner ports.BrokerSubscription
478+ demand bool
465479 once sync.Once
466480 }
467481
@@ -472,7 +486,9 @@ func (s *serviceSubscription) Close() {
472486 s.inner.Close()
473487 s.service.mu.Lock()
474488 delete(s.service.subs, s)
475- s.service.registry.SetDemand(false)
489+ if s.demand {
490+ s.service.registry.SetDemand(false)
491+ }
476492 s.service.mu.Unlock()
477493 })
478494 }
@@ -230,14 +230,27 @@ func (s *Service) Snapshot() ports.BrokerSnapshot { return s.registry.Snapshot()
230 // watches the snapshot, the registry observes on the demand cadence. Without230 // watches the snapshot, the registry observes on the demand cadence. Without
231 // a subscriber it performs no scheduled observations. Close balances demand.231 // a subscriber it performs no scheduled observations. Close balances demand.
232 func (s *Service) Subscribe() (ports.BrokerSubscription, error) {232 func (s *Service) Subscribe() (ports.BrokerSubscription, error) {
233+ return s.subscribe(true)
234+}
235+
236+// SubscribePassive is Subscribe for a read-only snapshot reader (vev ls, vev
237+// host list): it receives every publication but records no demand, so reading
238+// state never triggers an observation.
239+func (s *Service) SubscribePassive() (ports.BrokerSubscription, error) {
240+ return s.subscribe(false)
241+}
242+
243+func (s *Service) subscribe(demand bool) (ports.BrokerSubscription, error) {
233 s.mu.Lock()244 s.mu.Lock()
234 if s.closed {245 if s.closed {
235 s.mu.Unlock()246 s.mu.Unlock()
236 return nil, ports.BrokerAdmissionClosed247 return nil, ports.BrokerAdmissionClosed
237 }248 }
238- sub := &serviceSubscription{service: s, inner: s.registry.Subscribe()}249+ sub := &serviceSubscription{service: s, inner: s.registry.Subscribe(), demand: demand}
239 s.subs[sub] = struct{}{}250 s.subs[sub] = struct{}{}
240- s.registry.SetDemand(true)251+ if demand {
252+ s.registry.SetDemand(true)
253+ }
241 s.mu.Unlock()254 s.mu.Unlock()
242 return sub, nil255 return sub, nil
243 }256 }
@@ -462,6 +475,7 @@ func (s *Service) openContext(ctx context.Context) (context.Context, func()) {
462 type serviceSubscription struct {475 type serviceSubscription struct {
463 service *Service476 service *Service
464 inner ports.BrokerSubscription477 inner ports.BrokerSubscription
478+ demand bool
465 once sync.Once479 once sync.Once
466 }480 }
467 481
@@ -472,7 +486,9 @@ func (s *serviceSubscription) Close() {
472 s.inner.Close()486 s.inner.Close()
473 s.service.mu.Lock()487 s.service.mu.Lock()
474 delete(s.service.subs, s)488 delete(s.service.subs, s)
475- s.service.registry.SetDemand(false)489+ if s.demand {
490+ s.service.registry.SetDemand(false)
491+ }
476 s.service.mu.Unlock()492 s.service.mu.Unlock()
477 })493 })
478 }494 }
M internal/usecase/broker/service_test.go +42 -0
@@ -507,6 +507,48 @@ func TestServiceSubscribeDrivesRegistryDemand(t *testing.T) {
507507 require.Zero(t, registryDemandCount(registry))
508508 }
509509
510+// TestServiceSubscribeDemandByReader pins the Amendment 2 rule "reading state
511+// forces nothing": a passive subscription (vev ls, vev host list) receives
512+// publications but never records demand, while a watching client does.
513+func TestServiceSubscribeDemandByReader(t *testing.T) {
514+ tests := []struct {
515+ name string
516+ passive bool
517+ wantDemand int
518+ }{
519+ {name: "watching client records demand", passive: false, wantDemand: 1},
520+ {name: "passive reader records none", passive: true, wantDemand: 0},
521+ }
522+ for _, tt := range tests {
523+ t.Run(tt.name, func(t *testing.T) {
524+ authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector)
525+ admitted, err := authority.AdmitClient(context.Background())
526+ require.NoError(t, err)
527+ t.Cleanup(func() { require.NoError(t, admitted.Close()) })
528+ service := admitted.(*Service)
529+
530+ subscribe := service.Subscribe
531+ if tt.passive {
532+ subscribe = service.SubscribePassive
533+ }
534+ sub, err := subscribe()
535+ require.NoError(t, err)
536+ require.Equal(t, tt.wantDemand, registryDemandCount(registry))
537+
538+ require.NoError(t, registry.setHosts(hostRecords(registration(t, "user@host:22", 1))))
539+ select {
540+ case <-sub.Changed():
541+ case <-time.After(time.Second):
542+ t.Fatal("every subscription must observe registry publications")
543+ }
544+
545+ sub.Close()
546+ sub.Close()
547+ require.Zero(t, registryDemandCount(registry), "closing must balance exactly the demand it recorded")
548+ })
549+ }
550+}
551+
510552 func TestServiceSnapshotDelegates(t *testing.T) {
511553 authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector)
512554 service, err := authority.AdmitClient(context.Background())
@@ -507,6 +507,48 @@ func TestServiceSubscribeDrivesRegistryDemand(t *testing.T) {
507 require.Zero(t, registryDemandCount(registry))507 require.Zero(t, registryDemandCount(registry))
508 }508 }
509 509
510+// TestServiceSubscribeDemandByReader pins the Amendment 2 rule "reading state
511+// forces nothing": a passive subscription (vev ls, vev host list) receives
512+// publications but never records demand, while a watching client does.
513+func TestServiceSubscribeDemandByReader(t *testing.T) {
514+ tests := []struct {
515+ name string
516+ passive bool
517+ wantDemand int
518+ }{
519+ {name: "watching client records demand", passive: false, wantDemand: 1},
520+ {name: "passive reader records none", passive: true, wantDemand: 0},
521+ }
522+ for _, tt := range tests {
523+ t.Run(tt.name, func(t *testing.T) {
524+ authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector)
525+ admitted, err := authority.AdmitClient(context.Background())
526+ require.NoError(t, err)
527+ t.Cleanup(func() { require.NoError(t, admitted.Close()) })
528+ service := admitted.(*Service)
529+
530+ subscribe := service.Subscribe
531+ if tt.passive {
532+ subscribe = service.SubscribePassive
533+ }
534+ sub, err := subscribe()
535+ require.NoError(t, err)
536+ require.Equal(t, tt.wantDemand, registryDemandCount(registry))
537+
538+ require.NoError(t, registry.setHosts(hostRecords(registration(t, "user@host:22", 1))))
539+ select {
540+ case <-sub.Changed():
541+ case <-time.After(time.Second):
542+ t.Fatal("every subscription must observe registry publications")
543+ }
544+
545+ sub.Close()
546+ sub.Close()
547+ require.Zero(t, registryDemandCount(registry), "closing must balance exactly the demand it recorded")
548+ })
549+ }
550+}
551+
510 func TestServiceSnapshotDelegates(t *testing.T) {552 func TestServiceSnapshotDelegates(t *testing.T) {
511 authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector)553 authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector)
512 service, err := authority.AdmitClient(context.Background())554 service, err := authority.AdmitClient(context.Background())

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close