Routes may share one lease (affinity = "route") and skip crossbar's queue (queue = false)
route.go gains Route.Affinity/Queue with PerRoute(), Queues() and affinity validation (checkRoutes moved here; config.go calls it once). limiter.Track counts a request without holding or refusing it; a release hands the slot to a waiter only while in flight <= parallel. The proxy leases a PerRoute() route under an empty fingerprint (the row keeps the real one) and uses Track when Queues() is false. Implemented by Ornith (OpenCode); owner review removed a release-on-first-flush workaround for a race in the owner's given test (see implementer log). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,14 +71,6 @@ type Host struct {
|
||||
Wake *Wake `toml:"wake"`
|
||||
}
|
||||
|
||||
// Route is an ordered list of hosts to try, with an optional default model and
|
||||
// the peers allowed to reach it.
|
||||
type Route struct {
|
||||
Hosts []string `toml:"hosts"`
|
||||
DefaultModel string `toml:"default_model"`
|
||||
Peers []string `toml:"peers"`
|
||||
}
|
||||
|
||||
// Config is the whole file: what to listen on, tuning, hosts and routes.
|
||||
type Config struct {
|
||||
Listen string `toml:"listen"`
|
||||
@@ -117,8 +109,6 @@ const (
|
||||
DefaultIdentity = "off"
|
||||
)
|
||||
|
||||
var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// Load reads and parses the config file at path. An open failure is wrapped as
|
||||
// "config: …", the same shape as a decode failure.
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -345,54 +335,3 @@ func (c *Config) checkHosts() *Error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) checkRoutes(peersDefined map[string]bool, identityDefined bool) *Error {
|
||||
if len(c.Routes) == 0 {
|
||||
return &Error{Field: "routes", Msg: "at least one required"}
|
||||
}
|
||||
names := make([]string, 0, len(c.Routes))
|
||||
for name := range c.Routes {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
r := c.Routes[name]
|
||||
|
||||
if !routeName.MatchString(name) && !templateName.MatchString(name) {
|
||||
return &Error{Field: fmt.Sprintf("routes.%s", name), Msg: "must match [a-z0-9][a-z0-9-]*"}
|
||||
}
|
||||
|
||||
hostsField := fmt.Sprintf("routes.%s.hosts", name)
|
||||
if len(r.Hosts) == 0 {
|
||||
return &Error{Field: hostsField, Msg: "at least one required"}
|
||||
}
|
||||
seen := make(map[string]bool, len(r.Hosts))
|
||||
for _, h := range r.Hosts {
|
||||
if seen[h] {
|
||||
return &Error{Field: hostsField, Msg: "host listed twice"}
|
||||
}
|
||||
seen[h] = true
|
||||
if _, ok := c.Hosts[h]; !ok {
|
||||
return &Error{Field: hostsField, Msg: "unknown host"}
|
||||
}
|
||||
}
|
||||
|
||||
if r.DefaultModel != "" {
|
||||
served := false
|
||||
for _, h := range r.Hosts {
|
||||
if _, ok := c.Hosts[h].Models[r.DefaultModel]; ok {
|
||||
served = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !served {
|
||||
return &Error{Field: fmt.Sprintf("routes.%s.default_model", name), Msg: "not served by any host in route"}
|
||||
}
|
||||
}
|
||||
|
||||
if e := checkPeers(name, r.Peers, peersDefined[name], identityDefined, c.Identity); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package config_test
|
||||
|
||||
// v2.3 task 02: the affinity and queue route keys.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/config"
|
||||
)
|
||||
|
||||
const affinityBase = `
|
||||
listen = "127.0.0.1:1"
|
||||
[hosts.a]
|
||||
base_url = "http://a:1"
|
||||
models = { "m" = { } }
|
||||
[routes.plain]
|
||||
hosts = ["a"]
|
||||
[routes.convo]
|
||||
hosts = ["a"]
|
||||
affinity = "conversation"
|
||||
[routes.boxmaker]
|
||||
hosts = ["a"]
|
||||
affinity = "route"
|
||||
queue = false
|
||||
[routes."bm-*"]
|
||||
hosts = ["a"]
|
||||
affinity = "route"
|
||||
queue = false
|
||||
[routes.queued]
|
||||
hosts = ["a"]
|
||||
queue = true
|
||||
`
|
||||
|
||||
func TestAffinityAndQueueKeys(t *testing.T) {
|
||||
c, err := config.Parse(strings.NewReader(affinityBase))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
route string
|
||||
perRoute, queues bool
|
||||
}{
|
||||
{"plain", false, true}, // defaults: conversation affinity, queueing on
|
||||
{"convo", false, true},
|
||||
{"boxmaker", true, false},
|
||||
{"bm-agent-1", true, false}, // a template's keys reach its concrete routes
|
||||
{"queued", false, true},
|
||||
} {
|
||||
r, _, ok := c.Route(tc.route)
|
||||
if !ok {
|
||||
t.Fatalf("route %q not found", tc.route)
|
||||
}
|
||||
if r.PerRoute() != tc.perRoute || r.Queues() != tc.queues {
|
||||
t.Errorf("%s: PerRoute %v Queues %v, want %v %v", tc.route, r.PerRoute(), r.Queues(), tc.perRoute, tc.queues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityRejectsUnknownValues(t *testing.T) {
|
||||
for _, bad := range []string{`"session"`, `"Route"`, `1`} {
|
||||
text := strings.Replace(affinityBase, `affinity = "conversation"`, "affinity = "+bad, 1)
|
||||
_, err := config.Parse(strings.NewReader(text))
|
||||
if err == nil || !strings.Contains(err.Error(), "routes.convo.affinity") {
|
||||
t.Errorf("affinity = %s: err %v, want one naming routes.convo.affinity", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueMustBeABool(t *testing.T) {
|
||||
text := strings.Replace(affinityBase, "queue = true", `queue = "no"`, 1)
|
||||
if _, err := config.Parse(strings.NewReader(text)); err == nil {
|
||||
t.Error(`queue = "no" parsed; want an error`)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// templateName matches a route template: a valid route name ending in "-*".
|
||||
var templateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*-\*$`)
|
||||
|
||||
// routeName matches a route (or template) name: the pattern a concrete or template route key must
|
||||
// match, so a name with '*' or an invalid prefix never resolves.
|
||||
var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// Route is an ordered list of hosts to try, with an optional default model, the peers allowed to
|
||||
// reach it, how its requests are placed (affinity), and whether crossbar queues them.
|
||||
type Route struct {
|
||||
Hosts []string `toml:"hosts"`
|
||||
DefaultModel string `toml:"default_model"`
|
||||
Peers []string `toml:"peers"`
|
||||
Affinity string `toml:"affinity"` // "" or "conversation" (the default), or "route"
|
||||
Queue *bool `toml:"queue"` // nil means true
|
||||
}
|
||||
|
||||
// PerRoute reports affinity = "route": every request on the route (chat or control) shares one
|
||||
// lease per model, so the route lives on one host.
|
||||
func (r Route) PerRoute() bool {
|
||||
return r.Affinity == "route"
|
||||
}
|
||||
|
||||
// Queues reports whether the route's requests wait in (and can be refused by) crossbar's per-(host,
|
||||
// model) queue; false only for queue = false, which leaves queueing to the client's own slot.
|
||||
func (r Route) Queues() bool {
|
||||
return r.Queue == nil || *r.Queue
|
||||
}
|
||||
|
||||
// Route resolves a request route name: an exact entry wins; else the longest template
|
||||
// "<prefix>-*" whose prefix (including the dash) starts name with a non-empty remainder;
|
||||
// else ok is false. key is the config key that matched (the template's name for a template).
|
||||
@@ -38,3 +66,64 @@ func (c *Config) Route(name string) (r Route, key string, ok bool) {
|
||||
}
|
||||
return best, bestKey, true
|
||||
}
|
||||
|
||||
// checkRoutes validates and defaults one route's hosts, model, affinity and peers in a fixed order.
|
||||
// A name that is neither a valid route nor a template, a missing or unknown host, a default model no
|
||||
// host serves, an unrecognised affinity, or a peers list that breaks the identity contract each
|
||||
// wins as the first error.
|
||||
func (c *Config) checkRoutes(peersDefined map[string]bool, identityDefined bool) *Error {
|
||||
if len(c.Routes) == 0 {
|
||||
return &Error{Field: "routes", Msg: "at least one required"}
|
||||
}
|
||||
names := make([]string, 0, len(c.Routes))
|
||||
for name := range c.Routes {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
r := c.Routes[name]
|
||||
|
||||
if !routeName.MatchString(name) && !templateName.MatchString(name) {
|
||||
return &Error{Field: fmt.Sprintf("routes.%s", name), Msg: "must match [a-z0-9][a-z0-9-]*"}
|
||||
}
|
||||
|
||||
hostsField := fmt.Sprintf("routes.%s.hosts", name)
|
||||
if len(r.Hosts) == 0 {
|
||||
return &Error{Field: hostsField, Msg: "at least one required"}
|
||||
}
|
||||
seen := make(map[string]bool, len(r.Hosts))
|
||||
for _, h := range r.Hosts {
|
||||
if seen[h] {
|
||||
return &Error{Field: hostsField, Msg: "host listed twice"}
|
||||
}
|
||||
seen[h] = true
|
||||
if _, ok := c.Hosts[h]; !ok {
|
||||
return &Error{Field: hostsField, Msg: "unknown host"}
|
||||
}
|
||||
}
|
||||
|
||||
if r.DefaultModel != "" {
|
||||
served := false
|
||||
for _, h := range r.Hosts {
|
||||
if _, ok := c.Hosts[h].Models[r.DefaultModel]; ok {
|
||||
served = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !served {
|
||||
return &Error{Field: fmt.Sprintf("routes.%s.default_model", name), Msg: "not served by any host in route"}
|
||||
}
|
||||
}
|
||||
|
||||
switch r.Affinity {
|
||||
case "", "conversation", "route":
|
||||
default:
|
||||
return &Error{Field: fmt.Sprintf("routes.%s.affinity", name), Msg: `must be "conversation" or "route"`}
|
||||
}
|
||||
|
||||
if e := checkPeers(name, r.Peers, peersDefined[name], identityDefined, c.Identity); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user