mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
The queue held a *zapdb.DB directly. That pins it to one engine, and two engines able to open one directory is not hypothetical here: goleveldb and ZapDB both opened queues/common, each one's recovery deleted the files the other's manifest did not list, and git.hanzo.ai spent the day at 503 on a dangling ZapDB manifest. So the engine choice moves to one place. The queue now depends on github.com/luxfi/database and asserts the three capabilities it needs beyond plain key/value — Transactional, Sequencer, PrefixDropper (the latter two added in luxfi/database v1.21.4) — ONCE at open, naming the missing one. A backend that cannot back a durable queue fails to construct it, instead of failing the first time an item is pushed. Semantics are unchanged: same big-endian key layout, same FIFO-by-iteration, same atomic read-then-delete in PopItem, same crash-safe sequence. The wrapper does not transform keys (only an empty-key placeholder, which a queue key with a "<name>/i/" prefix can never hit), so this is on-disk compatible — an existing store opens as-is, with no migration. Two deliberate trade-offs: - PopItem now copies key and value and releases the iterator BEFORE deleting. The interface makes no promise that those buffers outlive Release, and deleting under a live iterator is the thing backends define least consistently. Copying two small slices is worth not depending on either. - Len loses the engine-specific "do not prefetch values" hint, which is not part of the interface. That is read amplification on a count, in exchange for not being pinned to one engine. ⚠ Cost worth knowing before this ships: importing luxfi/database/zapdb pulls in 17 modules, including the whole AWS SDK v2 S3/STS/SSO stack, because that package bundles S3/VFS replication that a local queue never uses. The right fix is splitting replication into its own subpackage in luxfi/database so consumers that do not replicate do not pay for it; this commit does not depend on that happening, it just costs binary size until it does. Deliberately NOT tagged. git.hanzo.ai recovered onto v1.26.29 hours ago after a day of outage on exactly this path; this rides the next release, not tonight's. Co-authored-by: Hanzo Dev <[email protected]>
308 lines
9.5 KiB
Go
308 lines
9.5 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package queue
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/database/zapdb"
|
|
)
|
|
|
|
// baseZapDB is the durable queue backend: an embedded FIFO on ZapDB.
|
|
//
|
|
// It replaces the leveldb/levelqueue pair. The requirement is an ordered,
|
|
// crash-safe queue that needs no server — the forge must start and index and
|
|
// deliver webhooks on a single box — and ZapDB is the embedded store this stack
|
|
// already runs (kms, cloud, mpc). leveldb was a second embedded engine doing
|
|
// the same job, reached through a third-party FIFO wrapper.
|
|
//
|
|
// It talks to ZapDB through github.com/luxfi/database rather than importing
|
|
// github.com/luxfi/zapdb directly. That is not ceremony: holding a *zapdb.DB
|
|
// pins this queue to one engine, and two engines able to open one directory is
|
|
// precisely what took git.hanzo.ai down — each engine's recovery deletes the
|
|
// files the other's manifest does not list, so goleveldb ate ZapDB's tables and
|
|
// the forge died at boot on a dangling manifest. One interface, one engine
|
|
// chosen in one place, and swapping it is a change here and nowhere else.
|
|
//
|
|
// Layout, all under one prefix per queue so RemoveAll is a single DropPrefix:
|
|
//
|
|
// <name>/i/<be-uint64> -> item payload (ordered: iteration IS FIFO order)
|
|
// <name>/u/<payload> -> {} (unique index, only when unique)
|
|
//
|
|
// Ordering comes from a persisted sequence rather than a counter in memory, so
|
|
// restarting mid-queue does not hand out an index that is already on disk and
|
|
// silently overwrite an unread item.
|
|
type baseZapDB struct {
|
|
db database.Database
|
|
dir string
|
|
|
|
// The three capabilities this queue needs beyond plain key/value. They are
|
|
// optional interfaces, so they are asserted ONCE at open: a backend that
|
|
// cannot provide them fails to construct the queue, instead of failing the
|
|
// first time an item is pushed.
|
|
txn database.Transactional
|
|
seqr database.Sequencer
|
|
dropper database.PrefixDropper
|
|
|
|
seq database.Sequence
|
|
cfg *BaseConfig
|
|
unique bool
|
|
|
|
// mu serialises PopItem. Reading the head and deleting it must be one step:
|
|
// two poppers racing between the read and the delete would both return the
|
|
// same item, and a queue that delivers a webhook twice is worse than one
|
|
// that is briefly slow.
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// ZapDB takes an EXCLUSIVE lock on its directory, and every queue in this
|
|
// process — issue indexer, code indexer, mail, webhook delivery, push update —
|
|
// is configured with the same DATADIR. Opening per queue therefore works
|
|
// exactly once and then fails with "Cannot acquire directory lock", which
|
|
// surfaces far from the cause as a half-built queue whose run loop nil-panics.
|
|
//
|
|
// So one handle per directory, reference counted, closed when the last queue
|
|
// using it closes. This is what the leveldb backend got from the nosql manager;
|
|
// it is stated here instead of inherited.
|
|
var (
|
|
sharedMu sync.Mutex
|
|
sharedDB = map[string]*sharedZapDB{}
|
|
)
|
|
|
|
type sharedZapDB struct {
|
|
db database.Database
|
|
refs int
|
|
}
|
|
|
|
func openSharedZapDB(dir string) (database.Database, error) {
|
|
sharedMu.Lock()
|
|
defer sharedMu.Unlock()
|
|
if sh := sharedDB[dir]; sh != nil {
|
|
sh.refs++
|
|
return sh.db, nil
|
|
}
|
|
// nil config selects the backend's defaults and silences its logger — the
|
|
// queue logs its own lifecycle and ZapDB's is noise here. nil metrics is
|
|
// accepted; the backend does not register any.
|
|
db, err := zapdb.New(dir, nil, "queue", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sharedDB[dir] = &sharedZapDB{db: db, refs: 1}
|
|
return db, nil
|
|
}
|
|
|
|
func closeSharedZapDB(dir string) error {
|
|
sharedMu.Lock()
|
|
defer sharedMu.Unlock()
|
|
sh := sharedDB[dir]
|
|
if sh == nil {
|
|
return nil
|
|
}
|
|
sh.refs--
|
|
if sh.refs > 0 {
|
|
return nil // another queue is still using this directory
|
|
}
|
|
delete(sharedDB, dir)
|
|
return sh.db.Close()
|
|
}
|
|
|
|
var _ baseQueue = (*baseZapDB)(nil)
|
|
|
|
func newBaseZapDBGeneric(cfg *BaseConfig, unique bool) (baseQueue, error) {
|
|
dir := cfg.DataFullDir
|
|
if dir == "" {
|
|
return nil, errors.New("queue: zapdb backend needs DATADIR")
|
|
}
|
|
dir = filepath.Clean(dir)
|
|
db, err := openSharedZapDB(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
q := &baseZapDB{db: db, dir: dir, cfg: cfg, unique: unique}
|
|
// Assert every optional capability up front, and name the missing one: a
|
|
// bare "type assertion failed" here would be read as a bug in this file
|
|
// rather than as "the configured backend cannot back a durable queue".
|
|
var ok bool
|
|
if q.txn, ok = db.(database.Transactional); !ok {
|
|
_ = closeSharedZapDB(dir)
|
|
return nil, fmt.Errorf("queue: backend %T is not database.Transactional; a queue needs atomic read-then-delete", db)
|
|
}
|
|
if q.seqr, ok = db.(database.Sequencer); !ok {
|
|
_ = closeSharedZapDB(dir)
|
|
return nil, fmt.Errorf("queue: backend %T is not database.Sequencer; a queue needs crash-safe ordering", db)
|
|
}
|
|
if q.dropper, ok = db.(database.PrefixDropper); !ok {
|
|
_ = closeSharedZapDB(dir)
|
|
return nil, fmt.Errorf("queue: backend %T is not database.PrefixDropper; RemoveAll needs it", db)
|
|
}
|
|
|
|
q.seq, err = q.seqr.GetSequence([]byte(cfg.QueueFullName+"/seq"), 100)
|
|
if err != nil {
|
|
_ = closeSharedZapDB(dir)
|
|
return nil, err
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
func newBaseZapDBSimple(cfg *BaseConfig) (baseQueue, error) {
|
|
return newBaseZapDBGeneric(cfg, false)
|
|
}
|
|
|
|
func newBaseZapDBUnique(cfg *BaseConfig) (baseQueue, error) {
|
|
return newBaseZapDBGeneric(cfg, true)
|
|
}
|
|
|
|
func (q *baseZapDB) itemPrefix() []byte { return []byte(q.cfg.QueueFullName + "/i/") }
|
|
func (q *baseZapDB) uniquePrefix() []byte { return []byte(q.cfg.QueueFullName + "/u/") }
|
|
|
|
func (q *baseZapDB) itemKey(idx uint64) []byte {
|
|
// Big-endian so lexicographic key order is numeric order — that is the only
|
|
// reason iteration yields FIFO.
|
|
k := make([]byte, 0, len(q.itemPrefix())+8)
|
|
k = append(k, q.itemPrefix()...)
|
|
var b [8]byte
|
|
binary.BigEndian.PutUint64(b[:], idx)
|
|
return append(k, b[:]...)
|
|
}
|
|
|
|
func (q *baseZapDB) uniqueKey(data []byte) []byte {
|
|
return append(q.uniquePrefix(), data...)
|
|
}
|
|
|
|
func (q *baseZapDB) PushItem(ctx context.Context, data []byte) error {
|
|
if q.unique {
|
|
has, err := q.HasItem(ctx, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if has {
|
|
return ErrAlreadyInQueue
|
|
}
|
|
}
|
|
idx, err := q.seq.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return q.txn.Update(func(txn database.Txn) error {
|
|
if err := txn.Put(q.itemKey(idx), data); err != nil {
|
|
return err
|
|
}
|
|
if q.unique {
|
|
return txn.Put(q.uniqueKey(data), []byte{})
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (q *baseZapDB) PopItem(ctx context.Context) ([]byte, error) {
|
|
// PopItem BLOCKS until an item is available or ctx ends — that is the
|
|
// contract every other backend implements via backoffRetErr, and the worker
|
|
// pool depends on it: returning (nil, nil) on an empty queue instead turns
|
|
// the worker loop into a spin and makes FlushWithContext report the
|
|
// cancellation as an error.
|
|
return backoffRetErr(ctx, backoffBegin, backoffUpper, infiniteTimerC,
|
|
func() (retry bool, data []byte, err error) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
|
|
err = q.txn.Update(func(txn database.Txn) error {
|
|
it := txn.NewIteratorWithPrefix(q.itemPrefix())
|
|
|
|
var key, val []byte
|
|
if it.Next() {
|
|
// Key/Value are only valid until Release, and the docs make no
|
|
// promise about the buffers outliving the iterator — copy both
|
|
// before releasing rather than aliasing into it.
|
|
key = append([]byte(nil), it.Key()...)
|
|
val = append([]byte(nil), it.Value()...)
|
|
}
|
|
iterErr := it.Error()
|
|
// Release BEFORE mutating the range being iterated. Deleting under
|
|
// a live iterator is the one thing backends define least
|
|
// consistently, and the queue does not need to hold it.
|
|
it.Release()
|
|
if iterErr != nil {
|
|
return iterErr
|
|
}
|
|
if key == nil {
|
|
return nil // empty; reported as retry below
|
|
}
|
|
|
|
if derr := txn.Delete(key); derr != nil {
|
|
return derr
|
|
}
|
|
if q.unique {
|
|
if derr := txn.Delete(q.uniqueKey(val)); derr != nil {
|
|
return derr
|
|
}
|
|
}
|
|
data = val
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, nil, err
|
|
}
|
|
return data == nil, data, nil
|
|
})
|
|
}
|
|
|
|
func (q *baseZapDB) HasItem(ctx context.Context, data []byte) (bool, error) {
|
|
if !q.unique {
|
|
// A non-unique queue has no membership index, and scanning every item on
|
|
// each push would make PushItem O(n). Matches the leveldb backend.
|
|
return false, nil
|
|
}
|
|
found := false
|
|
err := q.txn.View(func(txn database.Txn) error {
|
|
_, err := txn.Get(q.uniqueKey(data))
|
|
if errors.Is(err, database.ErrNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
found = true
|
|
return nil
|
|
})
|
|
return found, err
|
|
}
|
|
|
|
func (q *baseZapDB) Len(ctx context.Context) (int, error) {
|
|
n := 0
|
|
err := q.txn.View(func(txn database.Txn) error {
|
|
// Counting keys, not reading payloads. The engine-specific
|
|
// "don't prefetch values" hint is not part of the interface, so this
|
|
// trades a little read amplification for not being pinned to one engine.
|
|
it := txn.NewIteratorWithPrefix(q.itemPrefix())
|
|
defer it.Release()
|
|
for it.Next() {
|
|
n++
|
|
}
|
|
return it.Error()
|
|
})
|
|
return n, err
|
|
}
|
|
|
|
func (q *baseZapDB) RemoveAll(ctx context.Context) error {
|
|
return q.dropper.DropPrefix(q.itemPrefix(), q.uniquePrefix())
|
|
}
|
|
|
|
func (q *baseZapDB) Close() error {
|
|
// Release the sequence first: it holds a lease of unissued indices, and
|
|
// releasing writes the high-water mark back. Skipping it would leak the
|
|
// bandwidth window on every restart, so indices would march forward forever.
|
|
if q.seq != nil {
|
|
_ = q.seq.Release()
|
|
}
|
|
return closeSharedZapDB(q.dir)
|
|
}
|