Skip to content

Commit bf01b31

Browse files
committed
A24 W4b: build_frame cache + durable last-good + 3-stage fallback + cron/prerender fan-out
The persistence + trigger layer wrapping renderOnce (W4a). Completes the FaaS engine core. - buildframe.go: buildFrame (the sync path) = a TTL hot-cache short-circuit keyed (serial, fn) with the source version in the entry, so a version bump is an automatic miss (K7 cache-bust, no eviction signal needed) -> renderOnce -> durable last-good (faasstore.LastGoodPut) + cache on success -> the 3-stage fallback on failure: (1) warm in-memory frame, (2) durable last-good, (3) the embedded error frame (RETRY_WAKE). Never crashes the response; always a valid 30000-byte frame. serveLastGood is the non-sync path (prerender/schedule/webhook serve last-good only, never drive the worker inline). fanOut renders EVERY bound serial (faasstore.BoundSerials) with its own ctx.serial, writing per-(serial, fn) last-good (D24.12) — a per-serial failure keeps the prior frame. The render drive is an injectable seam (renderFunc) so cache/fallback/fan-out test without a worker. - main.go: handleRender now routes by trigger (render/sync -> buildFrame; else -> serveLastGood), and a background scheduler fans out enabled prerender/schedule functions on their interval (M1 interval-based; // TODO(cron): full 5-field cron). Boots the cache + scheduler + the real render seam (s.doRender). Gate: ephemeral postgres (0008+0009) + go test ./cmd/faas-supervisor (9 tests, all pass, none skipped): T6 cache parity (2 sync reqs -> 1 drive; force + version-bump re-render), T7 3-stage fallback (warm cache / DB last-good / error frame), T11 fan-out (2 serials -> per-serial frames) + routing (sync inline vs last-good); plus W4a's M4 parity / secret 2-class / wake. go vet + go build ./... clean. Also .gitignore: ignore stray single-package go-build binaries.
1 parent a3c0fec commit bf01b31

4 files changed

Lines changed: 483 additions & 14 deletions

File tree

backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
.env
33
# local bind-mount data (named volumes are managed by docker, not committed)
44
/data/
5+
# stray single-package `go build ./cmd/X` binaries (the images build via Dockerfile, not these)
56
/ingest
7+
/faas-supervisor
8+
/faas-egress
69
*.log
710

811
# Frontend (A19): the bun toolchain output. Rule order matters — exclude the
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"log"
7+
"sync"
8+
"time"
9+
10+
"github.com/open-picpak/backend/internal/faasproto"
11+
"github.com/open-picpak/backend/internal/faasstore"
12+
)
13+
14+
// triggerConfig is the per-function policy (Policy=Data) parsed from faas_functions.trigger_config.
15+
type triggerConfig struct {
16+
Mode string `json:"mode"` // render trigger: "sync" (default) | "prerender"
17+
TTLSec int `json:"ttl_s"` // hot-cache TTL for sync (default s.renderTTL)
18+
IntervalS int `json:"interval_s"` // prerender/schedule cadence
19+
Dither string `json:"dither"`
20+
Cron string `json:"cron"` // schedule // TODO(cron): full 5-field parsing; M1 uses interval_s
21+
}
22+
23+
func parseTriggerConfig(raw json.RawMessage) triggerConfig {
24+
var c triggerConfig
25+
if len(raw) > 0 {
26+
_ = json.Unmarshal(raw, &c)
27+
}
28+
return c
29+
}
30+
31+
// renderFunc is the injectable drive seam: production wires s.doRender (real M4 render); tests inject
32+
// a stub so build_frame's cache/last-good/fallback and the fan-out are exercised without a worker.
33+
type renderFunc func(ctx context.Context, fn *faasstore.Function, rctx faasproto.RequestCtx, force bool) RenderResult
34+
35+
func (s *supervisor) doRender(ctx context.Context, fn *faasstore.Function, rctx faasproto.RequestCtx, force bool) RenderResult {
36+
return renderOnce(ctx, s.pool, s.box, fn, rctx, RenderOpts{
37+
Secrets: SecretsReal,
38+
Limits: s.limits,
39+
Force: force,
40+
DitherDefault: s.ditherDefault,
41+
M4Sock: s.m4Sock,
42+
Timeout: time.Duration(s.limits.TimeoutMs)*time.Millisecond + 10*time.Second,
43+
EgressRegister: s.egress.register,
44+
EgressUnregister: s.egress.unregister,
45+
})
46+
}
47+
48+
// --- hot cache (in-memory TTL, keyed by (serial, fn); the entry carries the source version so a
49+
// version bump is an automatic miss — K7 cache-bust) ---
50+
51+
type frameKey struct {
52+
serial string
53+
fnID int64
54+
}
55+
56+
type frameEntry struct {
57+
version int
58+
packed []byte
59+
renderedAt time.Time
60+
}
61+
62+
type frameCache struct {
63+
mu sync.Mutex
64+
m map[frameKey]frameEntry
65+
}
66+
67+
func newFrameCache() *frameCache { return &frameCache{m: map[frameKey]frameEntry{}} }
68+
69+
func (c *frameCache) get(serial string, fnID int64) (frameEntry, bool) {
70+
c.mu.Lock()
71+
defer c.mu.Unlock()
72+
e, ok := c.m[frameKey{serial, fnID}]
73+
return e, ok
74+
}
75+
76+
func (c *frameCache) put(serial string, fnID int64, version int, packed []byte) {
77+
c.mu.Lock()
78+
defer c.mu.Unlock()
79+
c.m[frameKey{serial, fnID}] = frameEntry{version: version, packed: packed, renderedAt: s_now()}
80+
}
81+
82+
// s_now is a package-level clock so tests can keep it real; kept trivial (no injection needed yet).
83+
func s_now() time.Time { return time.Now() }
84+
85+
// syncInline reports whether a device GET /frame renders inline (render/sync) vs serves last-good
86+
// (render/prerender, schedule, webhook) — D24.2/D24.12.
87+
func (s *supervisor) syncInline(fn *faasstore.Function) bool {
88+
if fn.TriggerType != faasstore.TriggerRender {
89+
return false
90+
}
91+
return parseTriggerConfig(fn.TriggerConfig).Mode != "prerender"
92+
}
93+
94+
func (s *supervisor) ttlFor(fn *faasstore.Function) time.Duration {
95+
if c := parseTriggerConfig(fn.TriggerConfig); c.TTLSec > 0 {
96+
return time.Duration(c.TTLSec) * time.Second
97+
}
98+
return s.renderTTL
99+
}
100+
101+
// buildFrame is the sync render path: hot-cache short-circuit (K7 version-keyed) → renderOnce →
102+
// durable last-good + cache on success → the 3-stage fallback on failure (warm cache → DB last-good →
103+
// embedded error frame). It NEVER crashes the response and always returns a valid 30000-byte frame.
104+
func (s *supervisor) buildFrame(ctx context.Context, fn *faasstore.Function, rctx faasproto.RequestCtx, force bool) (packed []byte, status string, stale bool, wake int) {
105+
if !force {
106+
if e, ok := s.cache.get(rctx.Serial, fn.ID); ok && e.version == fn.Version && time.Since(e.renderedAt) < s.ttlFor(fn) {
107+
return e.packed, "ok", false, s.wakeDefault() // cache hit — no worker drive (protects upstream fetch, T6)
108+
}
109+
}
110+
res := s.render(ctx, fn, rctx, force)
111+
if res.Err == nil {
112+
s.cache.put(rctx.Serial, fn.ID, fn.Version, res.Packed)
113+
if err := faasstore.LastGoodPut(ctx, s.pool, rctx.Serial, fn.ID, res.Packed, "ok"); err != nil {
114+
log.Printf("faas: last-good put %s fn %d: %v", rctx.Serial, fn.ID, err)
115+
}
116+
return res.Packed, "ok", false, s.wakeFor(res.Meta)
117+
}
118+
// 3-stage fallback (D24.6), in order:
119+
if e, ok := s.cache.get(rctx.Serial, fn.ID); ok && e.packed != nil { // (1) warm in-memory frame
120+
return e.packed, "stale", true, s.retryWake
121+
}
122+
if lg, err := faasstore.LastGoodGet(ctx, s.pool, rctx.Serial, fn.ID); err == nil && lg != nil { // (2) durable last-good
123+
return lg.Packed, "stale", true, s.retryWake
124+
}
125+
return errorFrame, "error", true, s.retryWake // (3) embedded error frame, never calls the worker
126+
}
127+
128+
// serveLastGood is the non-sync path (prerender/schedule/webhook): the device request always serves
129+
// the durable last-good (written by the timer/cron), never driving the worker inline (D24.12).
130+
func (s *supervisor) serveLastGood(ctx context.Context, fn *faasstore.Function, rctx faasproto.RequestCtx) (packed []byte, status string, stale bool, wake int) {
131+
if lg, err := faasstore.LastGoodGet(ctx, s.pool, rctx.Serial, fn.ID); err == nil && lg != nil {
132+
return lg.Packed, lg.Status, lg.Status != "ok", s.wakeDefault()
133+
}
134+
return errorFrame, "error", true, s.retryWake // nothing rendered yet
135+
}
136+
137+
// fanOut renders a function for EVERY serial bound to it (D24.12), each with that serial as ctx.serial,
138+
// writing per-(serial, function) last-good. This is the writer for prerender/schedule/webhook triggers.
139+
// A per-serial failure leaves that serial's prior last-good intact (never overwrites good with error).
140+
func (s *supervisor) fanOut(ctx context.Context, fn *faasstore.Function, trigger faasproto.Trigger) (ok, failed int) {
141+
serials, err := faasstore.BoundSerials(ctx, s.pool, fn.ID)
142+
if err != nil {
143+
log.Printf("faas: fanOut bound serials fn %d: %v", fn.ID, err)
144+
return 0, 0
145+
}
146+
for _, serial := range serials {
147+
rctx := faasproto.RequestCtx{
148+
Serial: serial,
149+
Channel: s.channelOf(ctx, serial),
150+
Trigger: trigger,
151+
Now: nowOrDefault(""),
152+
}
153+
res := s.render(ctx, fn, rctx, false)
154+
if res.Err != nil {
155+
log.Printf("faas: fanOut render %s fn %d: %s", serial, fn.ID, res.Err.Kind)
156+
failed++
157+
continue
158+
}
159+
if err := faasstore.LastGoodPut(ctx, s.pool, serial, fn.ID, res.Packed, "ok"); err != nil {
160+
log.Printf("faas: fanOut last-good put %s fn %d: %v", serial, fn.ID, err)
161+
failed++
162+
continue
163+
}
164+
s.cache.put(serial, fn.ID, fn.Version, res.Packed)
165+
ok++
166+
}
167+
return ok, failed
168+
}
169+
170+
// channelOf reads a device's operator-owned channel (server-side, D20.5). Empty if unknown/absent.
171+
func (s *supervisor) channelOf(ctx context.Context, serial string) string {
172+
var ch string
173+
_ = s.pool.QueryRow(ctx, `SELECT channel FROM devices WHERE serial = $1`, serial).Scan(&ch)
174+
return ch
175+
}
176+
177+
func (s *supervisor) wakeDefault() int { return s.wake.NextWakeSeconds(time.Now()) }

0 commit comments

Comments
 (0)