177 lines
5.4 KiB
Go
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)
|
|
}
|