mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-01 02:24:34 -04:00
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>
83 lines
3.7 KiB
Go
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),
|
|
)
|