Router loest Tenant-Slug (aus JWT-Claim, API-05 vorausgesetzt) ueber die TEN-01-Registry in eine wiederverwendete Postgres-Verbindung auf. LRU-Cache (container/list) begrenzt die Zahl gleichzeitig offener Tenant-Pools auf maxOpen — bei Ueberschreitung wird der am laengsten ungenutzte Pool geschlossen, bevor ein neuer aufgemacht wird. Fehlender/unbekannter Tenant-Kontext liefert explizite Fehler (ErrMissingTenantContext / ErrUnknownTenant) statt stillschweigend zu routen. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Lasttest mit 6 simulierten Mandanten gegen maxOpen=2 — TestRouter_BoundsOpenConnectionsUnderLoad: OpenCount() bleibt nach jedem Resolve <= maxOpen, Verbindungszahl waechst nicht linear mit der Mandantenzahl. PASS. 2. Anfrage ohne/mit unbekanntem Tenant-Kontext abgewiesen — TestRouter_RejectsMissingOrUnknownTenant. PASS. 3. Verbindungswiederverwendung gemessen — TestRouter_ReusesConnectionForSameTenant: zweiter Resolve-Aufruf liefert exakt dieselbe *pgxpool.Pool-Instanz. PASS. Router ist eigenstaendig nutzbar/getestet, aber noch nicht in cmd/core/main.go verdrahtet — der JWT-Claim mit Tenant-Kontext (API-05) und das HTTP-Routing, das den Slug pro Request extrahiert, sind nicht Teil dieser Kachel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
137 lines
3.7 KiB
Go
137 lines
3.7 KiB
Go
package tenant
|
|
|
|
import (
|
|
"container/list"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
|
|
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
|
|
// (TEN-06 Akzeptanzkriterium 3).
|
|
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
|
|
|
|
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
|
|
// nicht existiert.
|
|
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
|
|
|
|
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
|
|
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
|
|
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
|
|
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
|
|
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
|
|
type Router struct {
|
|
registry *Registry
|
|
maxOpen int
|
|
|
|
mu sync.Mutex
|
|
order *list.List // vorne = zuletzt benutzt
|
|
items map[string]*list.Element // slug -> element mit *routerEntry
|
|
}
|
|
|
|
type routerEntry struct {
|
|
slug string
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewRouter(registry *Registry, maxOpen int) *Router {
|
|
if maxOpen < 1 {
|
|
maxOpen = 1
|
|
}
|
|
return &Router{
|
|
registry: registry,
|
|
maxOpen: maxOpen,
|
|
order: list.New(),
|
|
items: make(map[string]*list.Element),
|
|
}
|
|
}
|
|
|
|
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
|
|
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
|
|
// (Akzeptanzkriterium 2 / Pruefung 3).
|
|
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
|
|
if tenantSlug == "" {
|
|
return nil, ErrMissingTenantContext
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if el, ok := r.items[tenantSlug]; ok {
|
|
r.order.MoveToFront(el)
|
|
pool := el.Value.(*routerEntry).pool
|
|
r.mu.Unlock()
|
|
return pool, nil
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
|
|
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
|
|
t, err := r.registry.GetBySlug(ctx, tenantSlug)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
|
|
}
|
|
|
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
|
|
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
|
|
// Verbindung wieder schliessen und die vorhandene verwenden.
|
|
if el, ok := r.items[tenantSlug]; ok {
|
|
r.order.MoveToFront(el)
|
|
existing := el.Value.(*routerEntry).pool
|
|
pool.Close()
|
|
return existing, nil
|
|
}
|
|
|
|
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
|
|
r.items[tenantSlug] = el
|
|
|
|
if r.order.Len() > r.maxOpen {
|
|
r.evictOldest()
|
|
}
|
|
|
|
return pool, nil
|
|
}
|
|
|
|
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
|
|
// gehaltenem r.mu aufgerufen werden.
|
|
func (r *Router) evictOldest() {
|
|
oldest := r.order.Back()
|
|
if oldest == nil {
|
|
return
|
|
}
|
|
entry := oldest.Value.(*routerEntry)
|
|
r.order.Remove(oldest)
|
|
delete(r.items, entry.slug)
|
|
entry.pool.Close()
|
|
}
|
|
|
|
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
|
|
// dient Tests/Monitoring, um AC2 nachzuweisen.
|
|
func (r *Router) OpenCount() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.order.Len()
|
|
}
|
|
|
|
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
|
|
// Herunterfahren des Core-Prozesses.
|
|
func (r *Router) Close() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for el := r.order.Front(); el != nil; el = el.Next() {
|
|
el.Value.(*routerEntry).pool.Close()
|
|
}
|
|
r.order.Init()
|
|
r.items = make(map[string]*list.Element)
|
|
}
|