init
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user