better convert
This commit is contained in:
1 parent
2a66754f9e
commit
b128608316
7 files changed
+591
-20
No files matched your search
@@ -0,0 +1,154 @@
|
||||
package projection
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Emitter is the producer half of the seam: it frames a full projection as a
|
||||
// begin → chunk* → end snapshot stream (and per-batch deltas) and hands each
|
||||
// frame to a caller-supplied publish function. It is transport-agnostic — it
|
||||
// owns only the framing (the Meta* keys, chunking, epoch tagging); the caller
|
||||
// wires PublishFunc to its bus (rabbit.PublishEvent, etc.) and EncodeFunc to
|
||||
// its wire schema. This is the symmetric counterpart to Store/Decoder.
|
||||
//
|
||||
// One authoritative producer per projection: receivers' Stores assemble their
|
||||
// own .bin from this stream. Two producers publishing the same projection would
|
||||
// make receivers thrash between epochs — don't.
|
||||
//
|
||||
// catalog's finder writeradmin predates this and keeps its own framing for now;
|
||||
// new domains (tracking popularity/suggestions) use this.
|
||||
|
||||
// PublishFunc publishes one framed event: meta carries the framing keys
|
||||
// (snapshot phase, epoch, seq, count, tenant) already set; payload is the
|
||||
// chunk/delta body. The caller may add its own keys to meta before sending
|
||||
// (e.g. a country tag). A non-nil error aborts the current snapshot stream.
|
||||
type PublishFunc func(meta map[string]string, payload []byte) error
|
||||
|
||||
// EncodeFunc serialises a batch of wire values into a chunk/delta payload — the
|
||||
// inverse of Decoder, and the one place the domain wire schema lives on the
|
||||
// producer side. It MUST encode a nil/empty slice as a payload the matching
|
||||
// Decoder reads back as zero updates (begin/end frames carry no entries).
|
||||
// JSONEncode satisfies this for the generic Update[W] schema.
|
||||
type EncodeFunc[W any] func([]Update[W]) ([]byte, error)
|
||||
|
||||
// ErrEmptySnapshot is returned by Snapshot when handed no entries. An empty
|
||||
// snapshot would swap a receiver's base to nothing; refusing it mirrors the
|
||||
// catalog producer, which never propagates an empty catalog.
|
||||
var ErrEmptySnapshot = errors.New("projection: refusing to emit an empty snapshot (would wipe receivers)")
|
||||
|
||||
// DefaultChunkSize is the entries-per-chunk used when WithChunkSize isn't set.
|
||||
// Mirrors catalog's projectionSnapshotChunk.
|
||||
const DefaultChunkSize = 500
|
||||
|
||||
// Emitter frames and publishes a projection of wire type W.
|
||||
type Emitter[W any] struct {
|
||||
encode EncodeFunc[W]
|
||||
publish PublishFunc
|
||||
tenant string
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
// emitterConfig is set via options then copied onto the typed Emitter (options
|
||||
// can't be generic over W, so they carry the un-typed knobs).
|
||||
type emitterConfig struct {
|
||||
tenant string
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
// WithTenant tags every frame with a tenant namespace (lands in MetaTenant and,
|
||||
// for snapshots, in the receiver's base header).
|
||||
func WithTenant(tenant string) func(*emitterConfig) {
|
||||
return func(c *emitterConfig) { c.tenant = tenant }
|
||||
}
|
||||
|
||||
// WithChunkSize overrides the entries-per-chunk (default DefaultChunkSize).
|
||||
// Bounds per-message size; smaller = more frames.
|
||||
func WithChunkSize(n int) func(*emitterConfig) {
|
||||
return func(c *emitterConfig) {
|
||||
if n > 0 {
|
||||
c.chunkSize = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewEmitter builds an emitter. encode and publish are REQUIRED. Options:
|
||||
// WithTenant, WithChunkSize.
|
||||
func NewEmitter[W any](encode EncodeFunc[W], publish PublishFunc, opts ...func(*emitterConfig)) *Emitter[W] {
|
||||
cfg := emitterConfig{chunkSize: DefaultChunkSize}
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return &Emitter[W]{
|
||||
encode: encode,
|
||||
publish: publish,
|
||||
tenant: cfg.tenant,
|
||||
chunkSize: cfg.chunkSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot publishes the full entry set as a begin → chunk* → end stream tagged
|
||||
// with epoch (a monotonic Unix-ms stamp the caller provides). Returns the
|
||||
// number of chunk frames emitted. ErrEmptySnapshot if items is empty; a
|
||||
// publish/encode error aborts the stream (receivers keep their previous base)
|
||||
// and is returned. The same epoch must later tag any Delta built on this base.
|
||||
func (e *Emitter[W]) Snapshot(epoch int64, items []Update[W]) (chunks int, err error) {
|
||||
if len(items) == 0 {
|
||||
return 0, ErrEmptySnapshot
|
||||
}
|
||||
if err := e.frame(epoch, SnapshotBegin, 0, len(items), nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seq := 0
|
||||
for start := 0; start < len(items); start += e.chunkSize {
|
||||
end := start + e.chunkSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
if err := e.frame(epoch, SnapshotChunk, seq, 0, items[start:end]); err != nil {
|
||||
return seq, err
|
||||
}
|
||||
seq++
|
||||
}
|
||||
if err := e.frame(epoch, SnapshotEnd, seq, len(items), nil); err != nil {
|
||||
return seq, err
|
||||
}
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
// Delta publishes one delta batch tagged with the base epoch it builds on.
|
||||
// Entries with Deleted=true tombstone the key in receivers' overlays. A
|
||||
// receiver ignores a delta whose epoch predates the base it holds. Empty batch
|
||||
// is a no-op (no error).
|
||||
func (e *Emitter[W]) Delta(epoch int64, items []Update[W]) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return e.frame(epoch, "", 0, 0, items)
|
||||
}
|
||||
|
||||
// frame encodes items and publishes one frame. phase "" ⇒ delta (no snapshot
|
||||
// phase key). begin/end carry MetaCount; chunks/deltas carry the entries.
|
||||
func (e *Emitter[W]) frame(epoch int64, phase string, seq, count int, items []Update[W]) error {
|
||||
// Always encode — even begin/end with no entries. encode(nil) must yield a
|
||||
// payload the Decoder reads as zero updates (see EncodeFunc), so a
|
||||
// framing-unaware decode never sees an empty/undecodable body.
|
||||
payload, err := e.encode(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta := map[string]string{
|
||||
MetaEpoch: strconv.FormatInt(epoch, 10),
|
||||
}
|
||||
if e.tenant != "" {
|
||||
meta[MetaTenant] = e.tenant
|
||||
}
|
||||
if phase != "" {
|
||||
meta[MetaSnapshotPhase] = phase // presence marks a snapshot frame vs a delta
|
||||
meta[MetaSeq] = strconv.Itoa(seq)
|
||||
}
|
||||
if phase == SnapshotBegin || phase == SnapshotEnd {
|
||||
meta[MetaCount] = strconv.Itoa(count)
|
||||
}
|
||||
return e.publish(meta, payload)
|
||||
}
|
||||
Reference in new issue
Block a user