// Package kv is meta-core's storage engine: one string→string table in an // embedded SQLite file. // // # Why a key/value table and not a schema // // meta-core's data model is schema-free on purpose — a record is a set of flat // `file:/` keys, and a new metadata field is just a new key // (METADATA_KEYS.md). The engine keeps exactly that model: // // CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID // // is the only table that holds data, and it never needs a migration. What // Redis gave us that a bare KV would not — "list a record's fields", "find // records by field value", free-text search — comes from the table being // *ordered* (a record is a key range) and from two derived structures the // storage package maintains on top: an expression index over the field name // parsed out of the key, and an FTS5 table. Both are rebuildable from the kv // rows and never authoritative. // // It replaced Redis on 2026-10: holding everything in RAM cost ~6.6 GB on a // 169k-record corpus (Redis plus the API's in-memory copy needed to search // it), with the OOM kills that implies. The same data is ~2.5 GB on disk here // and the working set is a bounded page cache. // // # Concurrency // // SQLite allows one writer at a time, so every write goes through one writer // goroutine owning one connection. Concurrent Update calls are GROUP-COMMITTED: // the writer runs every request waiting at that moment inside one SQLite // transaction, each under its own SAVEPOINT (a failing request rolls back // alone), and commits once. A commit — writing the dirty pages of the table, // the field index and the FTS shadow tables to the WAL — was ~40% of the cost // of a record write, so sharing it is what makes concurrent ingest scale. // // Reads use a separate pool and see the last committed state (WAL). Inside // Update, read through the *Tx — the reader pool cannot see uncommitted rows — // and never call Update from inside an Update callback (the writer would wait // for itself). // // # Events // // Redis keyspace notifications are replaced by the transaction itself: every // Put/Delete of a key the Options.EventKey predicate selects is appended, in // the same transaction, to Options.EventStream as {type: set|del, key, ts}. // A change and its event therefore commit together or not at all. package kv import ( "context" "database/sql" "errors" "fmt" "log" "net/url" "os" "strconv" "strings" "sync" "sync/atomic" "time" _ "modernc.org/sqlite" ) // Options configures a Store. type Options struct { // EventStream receives a set/del entry for every changed key EventKey // selects. Empty disables change events. EventStream string // EventMaxLen is the approximate retention of EventStream. EventMaxLen int64 // EventKey selects the keys whose changes are published. EventKey func(key string) bool // WriterCacheMB / ReaderCacheMB size SQLite's page cache per connection. WriterCacheMB int ReaderCacheMB int // Readers is the size of the read connection pool. Readers int // Schema is extra DDL run at open (idempotent CREATE … IF NOT EXISTS), // for derived indexes owned by the caller. Schema string // OnChange is called inside the transaction for every key whose value // actually changed (created, modified or deleted), with the old and new // values — the hook derived counters hang off (Tx.AddCounter). It must not // write kv rows itself. OnChange func(tx *Tx, key, old string, hadOld bool, new string, hasNew bool) // NoKVTable opens the file without the kv table and its prepared // statements: the caller owns every table through Schema and writes them // with Tx.Exec / Tx.Query (meta-core's truth database). The kv methods // (Get, Put, streams, counters) must not be used on such a store. NoKVTable bool // Synchronous is SQLite's synchronous pragma; default NORMAL. A store that // is rebuildable from another (meta-core's cache) runs OFF. Synchronous string } // Store is an open database. type Store struct { path string opts Options w *sql.DB // single connection: the one writer r *sql.DB // read pool streamMu sync.Mutex streams map[string]*streamState // Hot write statements, prepared once on the writer connection: parsing // SQL on every Put was the single largest cost of a record write. stPut, stPutIfAbsent, stDelete, stGet, stInsert *sql.Stmt // Write accounting: how many Puts changed a value vs found it unchanged. puts, noops atomic.Int64 writes chan *writeReq writeMu sync.Mutex // held by the writer while a group runs closeOnce sync.Once stopped chan struct{} } // writeReq is one Update call waiting for the writer. type writeReq struct { fn func(tx *Tx) error done chan error } // maxGroup caps how many requests share one commit, bounding how long the // writer holds a transaction open. const maxGroup = 128 // Open opens (creating if needed) the database file at path. func Open(path string, opts Options) (*Store, error) { if opts.Readers <= 0 { opts.Readers = 8 } if opts.WriterCacheMB <= 0 { opts.WriterCacheMB = 64 } if opts.ReaderCacheMB <= 0 { opts.ReaderCacheMB = 16 } if opts.EventMaxLen <= 0 { opts.EventMaxLen = 100_000 } if opts.Synchronous == "" { opts.Synchronous = "NORMAL" } w, err := sql.Open("sqlite", dsn(path, opts.WriterCacheMB, false, opts.Synchronous)) if err != nil { return nil, fmt.Errorf("open writer: %w", err) } w.SetMaxOpenConns(1) w.SetMaxIdleConns(1) w.SetConnMaxLifetime(0) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if !opts.NoKVTable { if _, err := w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`); err != nil { w.Close() return nil, fmt.Errorf("create kv: %w", err) } } if opts.Schema != "" { // No deadline: on an existing store, the first open after a new index // is added builds it over every row (minutes at millions of keys). // Cancelling that just rolls it back and restarts it on the next boot. started := time.Now() if _, err := w.ExecContext(context.Background(), opts.Schema); err != nil { w.Close() return nil, fmt.Errorf("apply schema: %w", err) } if d := time.Since(started); d > 5*time.Second { log.Printf("[kv] schema applied in %s (indexes built)", d.Round(time.Second)) } } r, err := sql.Open("sqlite", dsn(path, opts.ReaderCacheMB, true, opts.Synchronous)) if err != nil { w.Close() return nil, fmt.Errorf("open readers: %w", err) } r.SetMaxOpenConns(opts.Readers) r.SetMaxIdleConns(opts.Readers) st := &Store{path: path, opts: opts, w: w, r: r, streams: make(map[string]*streamState)} // Fresh deadline: the schema step above may legitimately have taken minutes. ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) defer cancel() stmts := []struct { dst **sql.Stmt query string }{ {&st.stPut, `INSERT INTO kv(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`}, {&st.stPutIfAbsent, `INSERT INTO kv(k, v) VALUES(?, ?) ON CONFLICT(k) DO NOTHING`}, {&st.stDelete, `DELETE FROM kv WHERE k = ? RETURNING v`}, {&st.stGet, `SELECT v FROM kv WHERE k = ?`}, {&st.stInsert, `INSERT INTO kv(k, v) VALUES(?, ?)`}, } if opts.NoKVTable { stmts = nil } for _, p := range stmts { if *p.dst, err = w.PrepareContext(ctx, p.query); err != nil { r.Close() w.Close() return nil, fmt.Errorf("prepare: %w", err) } } st.writes = make(chan *writeReq) st.stopped = make(chan struct{}) go st.writer() return st, nil } // dsn builds a modernc DSN. WAL + synchronous=NORMAL is the durability class // Redis' AOF-everysec gave us: a power loss may drop the last transactions, // never corrupt the file. func dsn(path string, cacheMB int, readOnly bool, synchronous string) string { q := url.Values{} q.Add("_pragma", "busy_timeout(30000)") q.Add("_pragma", "journal_mode(WAL)") q.Add("_pragma", "synchronous("+synchronous+")") q.Add("_pragma", fmt.Sprintf("cache_size(-%d)", cacheMB*1024)) q.Add("_pragma", "temp_store(MEMORY)") q.Add("_pragma", "journal_size_limit(268435456)") if readOnly { q.Add("_pragma", "query_only(1)") } else { q.Add("_txlock", "immediate") } return "file:" + path + "?" + q.Encode() } // Close stops the writer and closes both pools, checkpointing the WAL into the // main file. func (s *Store) Close() error { s.closeOnce.Do(func() { close(s.stopped) }) s.writeMu.Lock() // wait for an in-flight group to finish defer s.writeMu.Unlock() _, _ = s.w.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) return errors.Join(s.r.Close(), s.w.Close()) } // Ping reports whether the database answers. func (s *Store) Ping(ctx context.Context) error { var one int return s.r.QueryRowContext(ctx, `SELECT 1`).Scan(&one) } // Path is the database file path. func (s *Store) Path() string { return s.path } // DB exposes the read pool for callers that own derived indexes (search). It // is read-only (query_only), so it cannot bypass Update. func (s *Store) DB() *sql.DB { return s.r } // FileSize returns the on-disk size of the database plus its WAL. func (s *Store) FileSize() int64 { var total int64 for _, suffix := range []string{"", "-wal"} { if fi, err := os.Stat(s.path + suffix); err == nil { total += fi.Size() } } return total } // Checkpoint folds the WAL back into the main file. TRUNCATE takes the writer // slot and waits for readers, so callers run it when idle. func (s *Store) Checkpoint(ctx context.Context) error { _, err := s.w.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`) return err } // KV is one key/value pair. type KV struct { K, V string } // Reader is the read surface shared by Store (committed state) and Tx (the // transaction's own view). Storage helpers take a Reader so they work both // inside and outside a write. type Reader interface { Get(ctx context.Context, k string) (string, bool, error) MGet(ctx context.Context, keys []string) (map[string]string, error) Range(ctx context.Context, prefix, after string, limit int) ([]KV, error) Count(ctx context.Context, prefix string) (int64, error) } type queryer interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } // PrefixEnd returns the smallest string greater than every string with the // given prefix, so [prefix, PrefixEnd(prefix)) is exactly the prefix's range. // An empty result means "unbounded". func PrefixEnd(prefix string) string { b := []byte(prefix) for i := len(b) - 1; i >= 0; i-- { if b[i] < 0xff { b[i]++ return string(b[:i+1]) } } return "" } func get(ctx context.Context, q queryer, k string) (string, bool, error) { var v string err := q.QueryRowContext(ctx, `SELECT v FROM kv WHERE k = ?`, k).Scan(&v) if errors.Is(err, sql.ErrNoRows) { return "", false, nil } if err != nil { return "", false, err } return v, true, nil } // mgetBatch stays well under SQLite's default 32766 bound-parameter cap. const mgetBatch = 500 func mget(ctx context.Context, q queryer, keys []string) (map[string]string, error) { out := make(map[string]string, len(keys)) for start := 0; start < len(keys); start += mgetBatch { end := min(start+mgetBatch, len(keys)) chunk := keys[start:end] args := make([]any, len(chunk)) for i, k := range chunk { args[i] = k } rows, err := q.QueryContext(ctx, `SELECT k, v FROM kv WHERE k IN (?`+strings.Repeat(",?", len(chunk)-1)+`)`, args...) if err != nil { return nil, err } for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { rows.Close() return nil, err } out[k] = v } if err := rows.Close(); err != nil { return nil, err } } return out, nil } // rangeQuery returns keys with prefix, strictly after `after` (when non-empty), // in key order, at most limit (<= 0 means all). func rangeQuery(ctx context.Context, q queryer, prefix, after string, limit int) ([]KV, error) { lo, loOp := prefix, ">=" if after != "" && after >= prefix { lo, loOp = after, ">" } query := `SELECT k, v FROM kv WHERE k ` + loOp + ` ?` args := []any{lo} if end := PrefixEnd(prefix); end != "" { query += ` AND k < ?` args = append(args, end) } query += ` ORDER BY k` if limit > 0 { query += ` LIMIT ?` args = append(args, limit) } rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []KV for rows.Next() { var kv KV if err := rows.Scan(&kv.K, &kv.V); err != nil { return nil, err } out = append(out, kv) } return out, rows.Err() } func count(ctx context.Context, q queryer, prefix string) (int64, error) { query := `SELECT count(*) FROM kv WHERE k >= ?` args := []any{prefix} if end := PrefixEnd(prefix); end != "" { query += ` AND k < ?` args = append(args, end) } var n int64 err := q.QueryRowContext(ctx, query, args...).Scan(&n) return n, err } // Get returns the committed value of k. func (s *Store) Get(ctx context.Context, k string) (string, bool, error) { return get(ctx, s.r, k) } // MGet returns the committed values of the keys that exist. func (s *Store) MGet(ctx context.Context, keys []string) (map[string]string, error) { return mget(ctx, s.r, keys) } // Range returns committed keys under prefix after `after`, in key order. func (s *Store) Range(ctx context.Context, prefix, after string, limit int) ([]KV, error) { return rangeQuery(ctx, s.r, prefix, after, limit) } // Count returns how many committed keys sit under prefix. func (s *Store) Count(ctx context.Context, prefix string) (int64, error) { return count(ctx, s.r, prefix) } // ScanPrefix walks every committed key under prefix in pages of `page`, each // page its own short read so a long walk never pins a WAL snapshot (which // would stop checkpoints and let the WAL grow without bound). func (s *Store) ScanPrefix(ctx context.Context, prefix string, page int, fn func([]KV) error) error { if page <= 0 { page = 5000 } after := "" for { batch, err := rangeQuery(ctx, s.r, prefix, after, page) if err != nil { return err } if len(batch) == 0 { return nil } if err := fn(batch); err != nil { return err } if len(batch) < page { return nil } after = batch[len(batch)-1].K } } // CountAll counts every row. A full scan — callers cache it. func (s *Store) CountAll(ctx context.Context) (int64, error) { var n int64 err := s.r.QueryRowContext(ctx, `SELECT count(*) FROM kv`).Scan(&n) return n, err } // ErrClosed is returned by Update on a closed store. var ErrClosed = errors.New("kv: store closed") // Update runs fn atomically: its writes (and the change events they publish) // commit together or not at all. fn may share a SQLite transaction with other // concurrent Updates (group commit) but is isolated from their failures by a // savepoint. Never call Update from inside fn. // // ctx bounds only the wait for the writer. Once fn starts it runs to // completion: interrupting a statement inside a shared transaction would roll // back every request in the group. func (s *Store) Update(ctx context.Context, fn func(tx *Tx) error) error { req := &writeReq{fn: fn, done: make(chan error, 1)} select { case s.writes <- req: case <-ctx.Done(): return ctx.Err() case <-s.stopped: return ErrClosed } return <-req.done } // writer is the single goroutine that executes writes. func (s *Store) writer() { for { var first *writeReq select { case first = <-s.writes: case <-s.stopped: return } group := []*writeReq{first} drain: for len(group) < maxGroup { select { case r := <-s.writes: group = append(group, r) default: break drain } } s.writeMu.Lock() s.runGroup(group) s.writeMu.Unlock() } } // group is the state shared by the requests of one commit. type group struct { tx *sql.Tx pending map[string]*streamPending stmts map[*sql.Stmt]*sql.Stmt counters map[string]int64 } func (g *group) snapshot() map[string]streamPending { out := make(map[string]streamPending, len(g.pending)) for k, p := range g.pending { out[k] = *p } return out } func (g *group) restore(snap map[string]streamPending) { g.pending = make(map[string]*streamPending, len(snap)) for k, p := range snap { p := p g.pending[k] = &p } } func (s *Store) runGroup(reqs []*writeReq) { ctx := context.Background() results := make([]error, len(reqs)) fail := func(err error) { for i, r := range reqs { if results[i] == nil { results[i] = err } r.done <- results[i] } } sqlTx, err := s.w.BeginTx(ctx, nil) if err != nil { fail(fmt.Errorf("begin: %w", err)) return } g := &group{tx: sqlTx} var commitHooks []func() for i, r := range reqs { if _, err := sqlTx.ExecContext(ctx, `SAVEPOINT req`); err != nil { _ = sqlTx.Rollback() fail(fmt.Errorf("savepoint: %w", err)) return } snap := g.snapshot() tx := &Tx{ctx: ctx, s: s, g: g} err := r.fn(tx) if err == nil { err = tx.flushEvents() } if err == nil { g.mergeCounters(tx.counters) commitHooks = append(commitHooks, tx.onCommit...) } if err != nil { results[i] = err if _, rbErr := sqlTx.ExecContext(ctx, `ROLLBACK TO req`); rbErr != nil { _ = sqlTx.Rollback() fail(fmt.Errorf("rollback to savepoint: %w (after %v)", rbErr, err)) return } g.restore(snap) } if _, err := sqlTx.ExecContext(ctx, `RELEASE req`); err != nil { _ = sqlTx.Rollback() fail(fmt.Errorf("release savepoint: %w", err)) return } } if err := (&Tx{ctx: ctx, s: s, g: g}).flushCounters(); err != nil { _ = sqlTx.Rollback() fail(err) return } if err := (&Tx{ctx: ctx, s: s, g: g}).flushHWM(); err != nil { _ = sqlTx.Rollback() fail(err) return } if err := sqlTx.Commit(); err != nil { fail(fmt.Errorf("commit: %w", err)) return } s.afterCommit(g.pending) // Hooks run in the writer goroutine, in commit order, before any request // returns: a caller that orders work by commit (meta-core's apply queue) // sees it in exactly that order. for _, h := range commitHooks { h() } for i, r := range reqs { r.done <- results[i] } } // Tx is one Update's view of the write transaction. Its reads see its own // writes (and those of earlier requests in the same group). type Tx struct { ctx context.Context s *Store g *group events []map[string]string counters map[string]int64 onCommit []func() quiet bool } // OnCommit registers fn to run after the transaction commits, only if this // request succeeded. Hooks run in the writer goroutine in commit order and // must not block or call Update. func (t *Tx) OnCommit(fn func()) { t.onCommit = append(t.onCommit, fn) } // Quiet stops this request's changes from publishing events (a bulk rebuild // whose consumers are told to take a snapshot instead). Counters and the // OnChange hook still run. func (t *Tx) Quiet() { t.quiet = true } // Query runs a raw multi-row query in the transaction. func (t *Tx) Query(query string, args ...any) (*sql.Rows, error) { return t.sqlTx().QueryContext(t.ctx, query, args...) } func (t *Tx) sqlTx() *sql.Tx { return t.g.tx } // stmt binds one of the store's prepared statements to the transaction. func (t *Tx) stmt(st *sql.Stmt) *sql.Stmt { if t.g.stmts == nil { t.g.stmts = make(map[*sql.Stmt]*sql.Stmt, 5) } bound := t.g.stmts[st] if bound == nil { bound = t.g.tx.StmtContext(t.ctx, st) t.g.stmts[st] = bound } return bound } // Context is the context the transaction runs under. func (t *Tx) Context() context.Context { return t.ctx } func (t *Tx) Get(ctx context.Context, k string) (string, bool, error) { var v string err := t.stmt(t.s.stGet).QueryRowContext(ctx, k).Scan(&v) if errors.Is(err, sql.ErrNoRows) { return "", false, nil } if err != nil { return "", false, err } return v, true, nil } func (t *Tx) MGet(ctx context.Context, keys []string) (map[string]string, error) { return mget(ctx, t.sqlTx(), keys) } func (t *Tx) Range(ctx context.Context, prefix, after string, limit int) ([]KV, error) { return rangeQuery(ctx, t.sqlTx(), prefix, after, limit) } func (t *Tx) Count(ctx context.Context, prefix string) (int64, error) { return count(ctx, t.sqlTx(), prefix) } // Exec runs a raw statement in the transaction — for the caller's derived // tables (FTS), never for kv rows (which would skip events). func (t *Tx) Exec(query string, args ...any) (sql.Result, error) { return t.sqlTx().ExecContext(t.ctx, query, args...) } // QueryRow runs a raw single-row query in the transaction. func (t *Tx) QueryRow(query string, args ...any) *sql.Row { return t.sqlTx().QueryRowContext(t.ctx, query, args...) } // Put sets k = v. A no-op — no row write, no index churn, no event — when k // already holds v. (Redis keyspace notifications published `set` even for an // unchanged value; with meta-core persisting the same gateway hits again on // every repeated query, those no-op writes were most of the write load.) func (t *Tx) Put(k, v string) error { _, err := t.Set(k, v) return err } // Set is Put that reports whether the value changed. func (t *Tx) Set(k, v string) (bool, error) { old, had, err := t.Get(t.ctx, k) if err != nil { return false, fmt.Errorf("put %q: %w", k, err) } if had && old == v { t.s.noops.Add(1) return false, nil } if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, k, v); err != nil { return false, fmt.Errorf("put %q: %w", k, err) } t.s.puts.Add(1) t.changed("set", k, old, had, v, true) return true, nil } // PutIfAbsent sets k = v only when k does not exist. Reports whether it wrote. func (t *Tx) PutIfAbsent(k, v string) (bool, error) { res, err := t.stmt(t.s.stPutIfAbsent).ExecContext(t.ctx, k, v) if err != nil { return false, fmt.Errorf("put %q: %w", k, err) } n, _ := res.RowsAffected() if n > 0 { t.changed("set", k, "", false, v, true) } return n > 0, nil } // Delete removes k. Reports whether it existed; publishes `del` only then, // as Redis did. func (t *Tx) Delete(k string) (bool, error) { var old string err := t.stmt(t.s.stDelete).QueryRowContext(t.ctx, k).Scan(&old) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("delete %q: %w", k, err) } t.changed("del", k, old, true, "", false) return true, nil } // DeletePrefix removes up to limit keys under prefix (limit <= 0: all) and // returns how many went. Bulk callers loop over separate Updates with a limit // so one transaction never grows to millions of rows. func (t *Tx) DeletePrefix(prefix string, limit int) (int64, error) { end := PrefixEnd(prefix) sel := `SELECT k FROM kv WHERE k >= ?` args := []any{prefix} if end != "" { sel += ` AND k < ?` args = append(args, end) } if limit > 0 { sel += ` ORDER BY k LIMIT ?` args = append(args, limit) } rows, err := t.sqlTx().QueryContext(t.ctx, `DELETE FROM kv WHERE k IN (`+sel+`) RETURNING k, v`, args...) if err != nil { return 0, fmt.Errorf("delete prefix %q: %w", prefix, err) } var gone []KV for rows.Next() { var kv KV if err := rows.Scan(&kv.K, &kv.V); err != nil { rows.Close() return 0, err } gone = append(gone, kv) } if err := rows.Close(); err != nil { return 0, err } for _, kv := range gone { t.changed("del", kv.K, kv.V, true, "", false) } return int64(len(gone)), nil } // changed records one real change: the event (when in scope) and the hook. func (t *Tx) changed(op, k, old string, hadOld bool, v string, hasNew bool) { t.event(op, k) if t.s.opts.OnChange != nil { t.s.opts.OnChange(t, k, old, hadOld, v, hasNew) } } // AddCounter adds delta to the integer counter stored at key. Deltas are // summed in memory and written once per commit (a hot counter touched by every // record in a group costs one row write, not one per record); they are // discarded with the request if it fails. A counter that reaches zero is // deleted. func (t *Tx) AddCounter(key string, delta int64) { if delta == 0 { return } if t.counters == nil { t.counters = make(map[string]int64) } t.counters[key] += delta } func (g *group) mergeCounters(c map[string]int64) { if len(c) == 0 { return } if g.counters == nil { g.counters = make(map[string]int64, len(c)) } for k, d := range c { g.counters[k] += d } } // flushCounters applies the group's counter deltas, bypassing Put (counters // are bookkeeping: no events, no hook). func (t *Tx) flushCounters() error { for k, d := range t.g.counters { if d == 0 { continue } cur, _, err := t.Get(t.ctx, k) if err != nil { return err } n, _ := strconv.ParseInt(cur, 10, 64) n += d if n <= 0 { if _, err := t.sqlTx().ExecContext(t.ctx, `DELETE FROM kv WHERE k = ?`, k); err != nil { return fmt.Errorf("counter %s: %w", k, err) } continue } if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, k, strconv.FormatInt(n, 10)); err != nil { return fmt.Errorf("counter %s: %w", k, err) } } return nil } // Counter reads a committed counter (0 when absent). func (s *Store) Counter(ctx context.Context, key string) (int64, bool, error) { v, ok, err := s.Get(ctx, key) if err != nil || !ok { return 0, ok, err } n, err := strconv.ParseInt(v, 10, 64) return n, true, err } // WriteStats reports how many Puts changed a value and how many found it // already in place, since open. func (s *Store) WriteStats() (changed, unchanged int64) { return s.puts.Load(), s.noops.Load() } func (t *Tx) event(op, k string) { if t.quiet || t.s.opts.EventStream == "" || t.s.opts.EventKey == nil || !t.s.opts.EventKey(k) { return } t.events = append(t.events, map[string]string{ "type": op, "key": k, "ts": fmt.Sprintf("%d", time.Now().UnixMilli()), }) } // flushEvents appends the queued change events to the event stream, inside // the transaction, right before commit. func (t *Tx) flushEvents() error { if len(t.events) == 0 { return nil } evs := t.events t.events = nil for _, e := range evs { if _, err := t.Append(t.s.opts.EventStream, t.s.opts.EventMaxLen, e); err != nil { return err } } return nil }