// Package store is the wallet's durable state: an in-memory cache over an asynchronous // store, with the writes batched behind it. // // The shape is forced by the platform. IndexedDB is asynchronous and every one of its calls // is a round trip to another thread, while the wallet's code wants to read and write its own // state synchronously, the way an embedded database's View and Update do. A shim that awaited // a Put would put a thread-hop inside the wallet's hot path, and a wallet that is matching a // filter batch writes dozens of times in a row. // // So the cache is the truth for the life of the process and the store behind it is a // write-behind copy: // // - Get, Put and Delete are memory operations that never block and never fail; // - a Put marks the key dirty and a coalescing flush writes the dirty set in one batch; // - a failed flush keeps the batch dirty and tries again, and the seed is what makes that // recoverable: a lost flush is a rescan, not a lost wallet. // // What this deliberately does not promise is durability at the moment of a write. It cannot: // the platform cannot promise it either. What it promises is that a write is never lost // silently, and that losing the tail of the log costs a rescan rather than a deposit. package store import ( "fmt" "sort" "strings" "sync" "time" ) // Op is one durable change: a value to store, or a key to remove. type Op struct { // Key is the document name. Key string // Value is the new value. Nil with Delete false is an empty value, which is different // from a deletion and is stored as empty. Value []byte // Delete marks a removal. Delete bool } // Persistence is the asynchronous side. // // Load is called once, at open, and returns everything the store holds: the wallet's data is // small enough that a full load is simpler and faster than a page-fetching API, and the seed // can rebuild it if it is wrong. Write is called with a batch and must apply all of it or // none of it: a half-applied batch would be a wallet whose cache and store disagree. type Persistence interface { // Load returns every stored key and value. Load() (map[string][]byte, error) // Write applies one batch atomically. Write(batch []Op) error } // Cache is the wallet's synchronous state. type Cache struct { mu sync.Mutex items map[string][]byte // dirty holds the keys changed since the last successful flush, with the value to // write and the version it was written at. A second change to the same key replaces // the first, which is the coalescing a filter batch needs, and the version is how a // flush knows whether the value it wrote is still the value the cache holds. dirty map[string]pendingOp version uint64 persist Persistence // done stops the flusher. done chan struct{} // stopped is closed when the flusher has exited. stopped chan struct{} // flushInterval is how long a dirty key may wait before the background flush takes it. // Zero means nothing flushes until the caller asks, which is what a test wants. flushInterval time.Duration // failures counts failed flushes, for a caller that wants to say so. failures int lastErr error } // DefaultFlushInterval is how long a change may wait before it reaches the store. // // Two seconds is the platform's compromise: every flush is a transaction on another thread, // and a wallet matching a block writes in bursts, so flushing per write would spend the sync // in round trips. What a lost window costs is a rescan of the last couple of seconds, and the // seed is what makes that free. const DefaultFlushInterval = 2 * time.Second // Open builds a cache over a persistence, flushing on the default interval. func Open(persist Persistence) (*Cache, error) { return OpenWith(persist, DefaultFlushInterval) } // OpenManual builds a cache that flushes only when it is asked. // // It is for tests, and for a caller that flushes at its own boundaries: a sync that has just // verified a batch of filters knows better than a timer when to write. func OpenManual(persist Persistence) (*Cache, error) { return OpenWith(persist, 0) } // OpenWith builds a cache over a persistence, loading what it holds. // // A persistence that cannot load is an error: a wallet that silently started empty would // show a zero balance for a funded wallet, which is the one mistake this package must not // make. func OpenWith(persist Persistence, interval time.Duration) (*Cache, error) { if persist == nil { return nil, fmt.Errorf("store: a cache needs a persistence") } loaded, err := persist.Load() if err != nil { return nil, fmt.Errorf("store: loading: %w", err) } cache := &Cache{ items: make(map[string][]byte, len(loaded)), dirty: map[string]pendingOp{}, persist: persist, done: make(chan struct{}), stopped: make(chan struct{}), flushInterval: interval, } for key, value := range loaded { copied := make([]byte, len(value)) copy(copied, value) cache.items[key] = copied } go cache.flusher() return cache, nil } // Get reads a value. func (c *Cache) Get(key string) ([]byte, bool) { c.mu.Lock() defer c.mu.Unlock() value, ok := c.items[key] if !ok { return nil, false } copied := make([]byte, len(value)) copy(copied, value) return copied, true } // Put stores a value in memory and marks it for the next flush. // // It returns immediately. The value is copied, so a caller reusing its buffer cannot change // what will be written. func (c *Cache) Put(key string, value []byte) error { if key == "" { return fmt.Errorf("store: a value needs a key") } if len(key) > 256 { return fmt.Errorf("store: the key %q is longer than 256 bytes", key[:32]) } copied := make([]byte, len(value)) copy(copied, value) c.mu.Lock() c.items[key] = copied c.version++ c.dirty[key] = pendingOp{op: Op{Key: key, Value: copied}, version: c.version} c.mu.Unlock() return nil } // Delete removes a value. func (c *Cache) Delete(key string) error { if key == "" { return fmt.Errorf("store: a deletion needs a key") } c.mu.Lock() delete(c.items, key) c.version++ c.dirty[key] = pendingOp{op: Op{Key: key, Delete: true}, version: c.version} c.mu.Unlock() return nil } // Keys returns the keys with a prefix, sorted. func (c *Cache) Keys(prefix string) []string { c.mu.Lock() defer c.mu.Unlock() out := make([]string, 0, len(c.items)) for key := range c.items { if strings.HasPrefix(key, prefix) { out = append(out, key) } } sort.Strings(out) return out } // Len reports how many keys are held. func (c *Cache) Len() int { c.mu.Lock() defer c.mu.Unlock() return len(c.items) } // flusher writes the dirty set whenever it is asked and, with an interval, on a timer. func (c *Cache) flusher() { defer close(c.stopped) c.mu.Lock() interval := c.flushInterval c.mu.Unlock() if interval <= 0 { // Manual: nothing happens until Close, which flushes the last window itself. <-c.done return } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-c.done: // A last attempt, so a clean shutdown does not leave the cache dirty when the // store is reachable. _ = c.Flush() return case <-ticker.C: _ = c.Flush() } } } // Flush writes the dirty set, and returns what went wrong. // // One batch, one write. A failure keeps every key dirty and is reported; the next call tries // again with whatever is dirty then, so a store that comes back gets everything that changed // while it was away. A value that changed during the write is newer than what was written, so // it stays dirty and the next flush carries it. func (c *Cache) Flush() error { c.mu.Lock() if len(c.dirty) == 0 { c.mu.Unlock() return nil } batch := make([]Op, 0, len(c.dirty)) written := make(map[string]uint64, len(c.dirty)) for key, pending := range c.dirty { batch = append(batch, pending.op) written[key] = pending.version } c.mu.Unlock() sort.Slice(batch, func(i, j int) bool { return batch[i].Key < batch[j].Key }) err := c.persist.Write(batch) c.mu.Lock() defer c.mu.Unlock() if err != nil { c.failures++ c.lastErr = err return fmt.Errorf("store: writing %d change(s): %w", len(batch), err) } for key, version := range written { if pending, ok := c.dirty[key]; ok && pending.version == version { delete(c.dirty, key) } } return nil } // pendingOp is one key's pending change and the version it was made at. type pendingOp struct { op Op version uint64 } // Failures reports how many flushes have failed, and why the last one did. func (c *Cache) Failures() (int, error) { c.mu.Lock() defer c.mu.Unlock() return c.failures, c.lastErr } // Close stops the flusher, after one last flush. func (c *Cache) Close() error { select { case <-c.done: // Already closing. default: close(c.done) } select { case <-c.stopped: case <-time.After(5 * time.Second): return fmt.Errorf("store: the flusher did not stop") } return c.Flush() }