Files
LocalAI/core/services/messaging/match_test.go
T
Ettore Di Giacinto d9c5d031a8 fix(distributed): order LISTEN and UNLISTEN on one lock
Unsubscribe decided a channel had lost its last subscriber under one
lock and issued the UNLISTEN after releasing it. A Subscribe on the same
root could decide to LISTEN in that window, and the two reached the
connection in the wrong order: the root ended up not listened with a
live subscription on it. It does not heal, because the next Subscribe
sees the registration already there and never re-LISTENs, so the whole
root stays deaf on that replica until the connection drops.

The decision and the statement it implies now happen under one lock,
held across both, at both call sites. A second lock and not the
registration lock: issuing waits on the listener goroutine, delivery
takes the registration lock, and holding that across the wait deadlocks
the carrier.

The race is spec'd through a barrier seam rather than by racing
goroutines. The natural window is microseconds wide, and a spec that
waits for it to open passes by luck; the seam scripts the interleaving,
so the spec decides in both directions.

Resolving a spilled message moved off the listener. PostgreSQL keeps
undelivered notifications in a shared, fixed-size queue, so a listener
that stops draining it can block COMMIT for every publisher on the
server, not only this one. The listener now only drains; one resolver
goroutine reads the row back and dispatches, which also keeps a spilled
message and an inline one on the same subject in the order they were
published.

Three wiring lines that could be deleted with the suite staying green:
the sweeper's start is now pinned by a Config interval, and the two
lines that carry the bus into the deployment now refuse to boot when
either is missing. A subscription can also report what it dropped, so
the party that missed a message is the party that can see it.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 03:05:12 +00:00

83 lines
3.7 KiB
Go

package messaging_test
import (
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/messaging"
)
// These rows are the shared contract between the PostgreSQL-backed carrier and
// the in-memory doubles. Both spellings of "does this filter match this
// subject" used to be copied by hand, and a drift between them reads as a peer
// that receives an event on one replica and not on another.
var _ = DescribeTable("SubjectMatches",
func(filter, subject string, expected bool) {
Expect(messaging.SubjectMatches(filter, subject)).To(Equal(expected))
},
Entry("an exact subject matches itself", "jobs.new", "jobs.new", true),
Entry("a different literal does not match", "jobs.new", "jobs.done", false),
Entry("matching is case sensitive", "Jobs.new", "jobs.new", false),
Entry("two wildcards each consume one token",
"agent.*.events.*", "agent.myagent.events.user1", true),
Entry("a wildcard does not match a missing token",
"agent.*.events.*", "agent.myagent.events", false),
Entry("a wildcard does not swallow two tokens",
"agent.*.events.*", "agent.a.b.events.u", false),
Entry("a wildcard does not match a longer subject",
"agent.*.events.*", "agent.myagent.events.user1.extra", false),
Entry("a middle wildcard matches one token",
"jobs.*.progress", "jobs.abc.progress", true),
Entry("a middle wildcard does not match two tokens",
"jobs.*.progress", "jobs.abc.def.progress", false),
Entry("a middle wildcard does not match zero tokens",
"jobs.*.progress", "jobs.progress", false),
Entry("a bare wildcard matches exactly one token", "*", "jobs", true),
Entry("a bare wildcard does not match two tokens", "*", "jobs.new", false),
// The sanitizer folds '.', '*' and '>' out of an id, so a model id that
// looks like several tokens still occupies exactly one, which is the only
// reason the staging wildcard can be a single '*'.
Entry("the staging wildcard matches a sanitized multi-part model id",
messaging.SubjectStagingProgressWildcard, messaging.SubjectStagingProgress("a.b.c"), true),
// '>' is refused rather than implemented: no surviving subscription uses
// it, and a caller who writes one must get nothing rather than everything.
Entry("a tail wildcard filter matches nothing", "a.>", "a.b", false),
Entry("a tail wildcard filter matches nothing even one token deep", "a.>", "a.b.c", false),
Entry("a bare tail wildcard matches nothing", ">", "anything", false),
// This row is the one that proves the '>' refusal is checked BEFORE the
// exact-equality fast path, which is where a naive implementation leaks.
Entry("a tail wildcard filter does not even match itself", "a.>", "a.>", false),
)
var _ = DescribeTable("ValidFilter",
func(filter string, wantErr bool) {
err := messaging.ValidFilter(filter)
if wantErr {
// The CLASS, not merely "an error". Carriers match on
// ErrUnsupportedFilter to tell "this caller asked for something we
// do not implement" apart from "the store is unhappy", so the
// definition has to pin what the callers match on.
Expect(errors.Is(err, messaging.ErrUnsupportedFilter)).To(BeTrue(), "got %v", err)
return
}
Expect(err).ToNot(HaveOccurred())
},
Entry("an empty filter is refused", "", true),
Entry("a tail wildcard is refused", "a.>", true),
Entry("a bare tail wildcard is refused", ">", true),
Entry("an embedded tail wildcard is refused", "a.>.b", true),
Entry("an empty middle token is refused", "a..b", true),
Entry("an empty leading token is refused", ".a", true),
Entry("an empty trailing token is refused", "a.", true),
Entry("a single-token wildcard is accepted", "a.*.b", false),
Entry("a literal filter is accepted", "a.b", false),
Entry("a bare wildcard is accepted", "*", false),
)