Files
pages/internal/site/registry.go
T
2026-08-15 07:13:00 +00:00

177 lines
5.4 KiB
Go

package site
import (
"context"
"maps"
"sync"
"sync/atomic"
"github.com/iceBear67/simplepages/internal/store"
)
// ProjectConfig is the serving-time part of a project row, copied out so the
// read path never touches the database. It is replaced wholesale, never edited
// in place, so a reader always sees one consistent set of settings.
type ProjectConfig struct {
IndexFile string
NotFoundFile string
SPAFallback bool
CacheControl string
}
// ConfigOf extracts what serving needs from a project row.
func ConfigOf(p *store.Project) *ProjectConfig {
return &ProjectConfig{
IndexFile: p.IndexFile,
NotFoundFile: p.NotFoundFile,
SPAFallback: p.SPAFallback,
CacheControl: p.CacheControl,
}
}
// Project is one project's live serving state.
//
// Both fields are atomic pointers to immutable values, so every read on the
// serving path is one atomic load and nothing else. Writers are already
// serialised per project by the deploy service's lock; the atomics are here for
// the readers, not for mutual exclusion.
type Project struct {
ID int64
Name string
cfg atomic.Pointer[ProjectConfig]
active atomic.Pointer[Deployment]
}
// NewProject builds a registry entry with no deployment activated yet.
func NewProject(p *store.Project) *Project {
sp := &Project{ID: p.ID, Name: p.Name}
sp.cfg.Store(ConfigOf(p))
return sp
}
// Active is the deployment this project is serving, or nil if it has never
// activated one.
//
// A request handler calls this exactly once, at the top, and uses the value it
// gets for the rest of the request. Calling it a second time within one request
// is a bug: the two loads could straddle an activation and produce a response
// assembled from two different versions, which is the exact failure this whole
// program exists to prevent.
func (p *Project) Active() *Deployment { return p.active.Load() }
// Config is the project's serving settings.
func (p *Project) Config() *ProjectConfig { return p.cfg.Load() }
// Activate publishes a snapshot. This single store is the version switch.
func (p *Project) Activate(d *Deployment) { p.active.Store(d) }
// SetConfig swaps in new serving settings.
func (p *Project) SetConfig(c *ProjectConfig) { p.cfg.Store(c) }
// Registry maps project names to their live state.
//
// Reads are overwhelmingly more common than writes — every static request does
// one, while the set of projects changes on the order of minutes to days — so
// the map is copy-on-write behind an atomic pointer: readers get a lock-free,
// allocation-free lookup against a consistent snapshot of the whole set, and
// writers pay O(n) to clone it. A RWMutex would put one shared cache line in
// every request's path for no benefit at this write rate.
//
// Note the layering: activating a deployment does *not* rebuild this map. It
// stores into the Project the map already points at, so the copy is reserved for
// changes to the project set itself.
type Registry struct {
mu sync.Mutex // serialises writers only; readers never take it
byName atomic.Pointer[map[string]*Project]
}
func NewRegistry() *Registry {
r := &Registry{}
r.byName.Store(&map[string]*Project{})
return r
}
// Lookup finds a project by the name in the URL.
func (r *Registry) Lookup(name string) (*Project, bool) {
p, ok := (*r.byName.Load())[name]
return p, ok
}
// Len is the number of projects the registry knows about.
func (r *Registry) Len() int { return len(*r.byName.Load()) }
// Projects returns the current entries in no particular order.
func (r *Registry) Projects() []*Project {
m := *r.byName.Load()
out := make([]*Project, 0, len(m))
for _, p := range m {
out = append(out, p)
}
return out
}
// ResolveProject satisfies auth.ProjectResolver, which lets the ownership guard
// on the hot deployment endpoints answer from memory instead of querying.
//
// It is only safe in that role because the registry is fully built from the
// database before the listeners start: a partially populated registry would
// report someone's own project as unknown.
func (r *Registry) ResolveProject(_ context.Context, name string) (int64, error) {
p, ok := r.Lookup(name)
if !ok {
return 0, store.ErrNotFound
}
return p.ID, nil
}
// Replace swaps in a whole new project set, which is how startup publishes the
// registry it built from the database.
func (r *Registry) Replace(ps []*Project) {
m := make(map[string]*Project, len(ps))
for _, p := range ps {
m[p.Name] = p
}
r.mu.Lock()
defer r.mu.Unlock()
r.byName.Store(&m)
}
// Put adds or updates a project and returns its live entry.
//
// An entry that is already present is kept rather than rebuilt, so a settings
// change does not disturb the deployment the project is currently serving.
func (r *Registry) Put(p *store.Project) *Project {
r.mu.Lock()
defer r.mu.Unlock()
old := *r.byName.Load()
if sp, ok := old[p.Name]; ok && sp.ID == p.ID {
sp.SetConfig(ConfigOf(p))
return sp
}
sp := NewProject(p)
next := maps.Clone(old)
if next == nil {
next = make(map[string]*Project, 1)
}
next[p.Name] = sp
r.byName.Store(&next)
return sp
}
// Delete drops a project. Requests for it start 404ing as soon as the new map
// is stored; requests already reading a snapshot of it finish normally.
func (r *Registry) Delete(name string) {
r.mu.Lock()
defer r.mu.Unlock()
old := *r.byName.Load()
if _, ok := old[name]; !ok {
return
}
next := maps.Clone(old)
delete(next, name)
r.byName.Store(&next)
}