IsleDB / Docs / Go API reference

Go API reference

Embed IsleDB in a Go program: open a database on your bucket, write and read keys, follow the change feed, and run maintenance.

This guide covers the public API most applications use, with the behavior that affects correctness: when writes are durable, when readers see them, how long a read view lasts, how change-feed cursors work, and how maintenance runs in its own process.

import "github.com/ankur-anand/isledb"

Overview

An IsleDB database is one object-store bucket (or container) plus a prefix. A DB value is the local runtime for that prefix, and it hands out four kinds of handle:

Handle Per DB Safe for concurrent use Purpose
Writer One Yes Buffer writes and commit them
Reader One Yes Point reads, scans, iterators, snapshots
ChangeReader Any number Yes; each caller owns its cursors Consume the feed of committed writes
Maintenance One Close may run alongside Run or RunOnce Compact, checkpoint, retain, reclaim

Writer and maintenance ownership are also fenced through the object store, so two processes can never act as the same owner at once. Readers and change readers scale freely: open the same prefix from as many processes as you like.

How a write becomes visible:

Put / Delete
    -> buffered in the writer
    -> Flush, background flush, Drain or Close
    -> committed to the manifest
    -> visible to a newly opened or refreshed reader

Quick start

This example turns off timed background flushing, so the explicit Flush is the moment the write becomes durable and visible.

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/ankur-anand/isledb"
)

func main() {
    if err := run(context.Background()); err != nil {
        log.Fatal(err)
    }
}

func run(ctx context.Context) error {
    db, err := isledb.Open(
        ctx,
        "s3://my-bucket?region=us-east-1",
        isledb.DBOptions{Prefix: "accounts"},
    )
    if err != nil {
        return err
    }
    defer func() { _ = db.Close() }()

    writerOptions := isledb.DefaultWriterOptions()
    writerOptions.Flush.Interval = 0

    writer, err := db.OpenWriter(ctx, writerOptions)
    if err != nil {
        return err
    }
    if _, err := writer.Put(ctx, []byte("user:1"), []byte("Ankur")); err != nil {
        return err
    }
    if err := writer.Flush(ctx); err != nil {
        return err
    }
    if err := writer.Close(ctx); err != nil {
        return err
    }

    reader, err := db.OpenReader(
        ctx,
        isledb.DefaultReaderOpenOptions("./isledb-cache"),
    )
    if err != nil {
        return err
    }
    defer func() { _ = reader.Close() }()

    value, found, err := reader.Get(ctx, []byte("user:1"))
    if err != nil {
        return err
    }
    if found {
        fmt.Printf("user:1 = %s\n", value)
    }
    return nil
}

Open takes a Go Cloud bucket URL. The production schemes are s3://, gs:// and azblob:// (S3-compatible stores work through s3://). Credentials come from the matching Go Cloud driver and cloud SDK.

file:// and mem:// buckets are for development and tests. They behave correctly within one process, but across processes their conditional writes are not atomic: never point two processes at the same file:// directory, and never use either in production.

Open and close a database

func Open(ctx context.Context, bucketURL string, opts DBOptions) (*DB, error)
func OpenBucket(ctx context.Context, bucket *blob.Bucket, bucketName string, opts DBOptions) (*DB, error)

type DBOptions struct {
    Prefix     string
    ChangeFeed *ChangeFeedOptions
    SSTOutput  SSTOutputOptions
    Policy     StorePolicy
}

type StorePolicy struct {
    MaxPinnedViewAge time.Duration
}
func (db *DB) OpenWriter(ctx context.Context, opts WriterOptions) (*Writer, error)
func (db *DB) OpenReader(ctx context.Context, opts ReaderOpenOptions) (*Reader, error)
func (db *DB) OpenChangeReader(ctx context.Context, opts ...ChangeReaderOptions) (*ChangeReader, error)
func (db *DB) OpenMaintenance(ctx context.Context, opts MaintenanceOptions) (*Maintenance, error)
func (db *DB) Close() error

DB.Close closes any handles still open on that DB. It is process-level cleanup: close your handles yourself when their errors matter. An open writer is closed with a fixed 30-second deadline and no retry, so if writes may be in flight, run Drain and Writer.Close first; see Shutting down.

Write data

func (w *Writer) Put(ctx context.Context, key, value []byte) (uint64, error)
func (w *Writer) PutWithTTL(ctx context.Context, key, value []byte, ttl time.Duration) (uint64, error)
func (w *Writer) Delete(ctx context.Context, key []byte) (uint64, error)
func (w *Writer) Apply(ctx context.Context, b *Batch) (uint64, error)
func (w *Writer) Flush(ctx context.Context) error
func (w *Writer) WaitCommitted(ctx context.Context, seq uint64) (ViewPosition, error)
func (w *Writer) CommittedSequence() uint64
func (w *Writer) AcceptedSequence() uint64
func (w *Writer) Epoch() uint64
func (w *Writer) CommitStatus(epoch, seq uint64) (CommitStatus, error)
func (w *Writer) State() WriterState
func (w *Writer) StopWrites()
func (w *Writer) Drain(ctx context.Context) error
func (w *Writer) Close(ctx context.Context) error
Method What it does
Put, PutWithTTL, Delete Buffer the write in memory and return its sequence number
Apply Buffer a batch of writes so they commit together; return the last sequence
Flush Commit everything buffered so far, and wait for it
WaitCommitted Wait until a given sequence is committed
CommittedSequence The highest committed sequence, without waiting
AcceptedSequence The highest sequence handed out, committed or not
Epoch The writer's fence epoch, one above the writer before it
CommitStatus Whether a write, named by epoch and sequence, was committed
State One consistent snapshot of the writer, for probes and dashboards
StopWrites Refuse new writes from now on; keep committing the ones accepted
Drain StopWrites, then commit everything accepted, retrying until ctx ends
Close Make one last commit attempt and finish the writer

Every method is safe to call from any goroutine.

Sequences. Each write gets the next sequence number, in the order writes are accepted. Sequences continue across writers from the highest committed one, and a change-feed consumer sees the same number as Change.Sequence. A writer taking over reuses the sequences its predecessor accepted but did not commit, so a sequence names a write only together with the writer's Epoch.

Validation. An empty or oversized key, an oversized value, or a negative TTL returns an error wrapping ErrInvalidMutation, so you know not to retry it. A TTL of zero means no expiration.

TTLs. Readers hide expired values. Expiry does not delete anything immediately; compaction removes expired data later.

Write several keys atomically

Put and Delete each commit on their own: between two of them the writer can start a new memtable, so a reader may see the first without the second. Apply buffers a whole batch under one lock and in one memtable, so readers see all of it or none of it, and the change feed publishes it all at once, in one change batch. A feed consumer that reads with a limit may still receive the batch over several pages; the feed does not mark where a batch ends.

b := isledb.NewBatch() // or: var b isledb.Batch
b.Put([]byte("order/42"), order)
b.Put([]byte("stock/7"), stock)
b.Delete([]byte("cart/42"))

last, err := writer.Apply(ctx, b)
if err != nil {
    return err // nothing was applied; see below
}
position, err := writer.WaitCommitted(ctx, last) // durable, all of it

Is my write durable?

A write is durable once it is committed to object storage. To acknowledge a client only then:

seq, err := writer.Put(ctx, key, value)
if err != nil {
    return err
}
position, err := writer.WaitCommitted(ctx, seq)
if err != nil {
    return err // see the table below
}
// The write is in object storage, visible to any reader at or past position.

WaitCommitted(ctx, seq) waits until that write, and every one before it, is committed. It does not flush by itself: writes commit with the next background flush, Flush, Drain or Close, so many waiters share one commit, and with healthy storage the wait is at most one flush interval. Without a flush interval, call Flush.

WaitCommitted returns The write
a position, nil is committed, and visible at that view position
ErrFenced was not committed and never will be: another writer took over (if more than two took over before this one learned of it, ask the current writer's CommitStatus)
a context error is not known yet: it is still being retried and may land
ErrWriterClosed is not known: Close finished the writer before confirming it, and its last attempt may have landed

Treat an unknown write as unknown, not lost, and make retries idempotent (for example, Put the same key and value again).

Read your own writes. The position WaitCommitted returns is a manifest log position: a Reader whose ViewPosition() is at or past it sees the write. Hand it to the client with the write's acknowledgement; a read that carries it is served once the reader's view has caught up, after a Refresh or the read cache's next ring. Unlike sequences, positions are never reused by a later writer.

Did it commit after a failover?

A client whose wait ended without an answer can ask the writer that holds the database now. Keep the epoch with each sequence handed out:

seq, err := writer.Put(ctx, key, value)
token := WriteToken{Epoch: writer.Epoch(), Seq: seq} // your type

// Later, on whichever writer is current:
status, err := current.CommitStatus(token.Epoch, token.Seq)

Each writer's fence records where the writer before it stopped, and where the one before that did, so CommitStatus answers exactly for this writer and the two before it:

Write of CommitStatus returns
this writer CommitDone or CommitPending; once fenced, CommitDone or CommitNever
one or two takeovers back CommitDone or CommitNever
three or more takeovers back CommitNever above the highest sequence committed when this writer took over; CommitUnknown at or below it, since a writer in between may have reused it
a later writer ErrFenced: ask that one

CommitStatus does no I/O, so an HTTP endpoint on the writer can answer from memory. A fenced writer learns where its commits ended from the fence that replaced it, a commit whose response was lost included, and WaitCommitted returns nil for those writes rather than ErrFenced. Where every write's outcome must stay knowable across any number of takeovers, store a request ID in the value written, and read it back after Refresh.

Writer lifecycle

State().Status reports where the writer is:

Status Entered by Put / Delete Flush Commits
WriterOpen OpenWriter accepted works yes
WriterStopped StopWrites, Drain, or Close starting ErrWritesStopped works yes, including background flushes
WriterClosed Close finishing ErrWriterClosed ErrWriterClosed no
WriterFenced another writer taking over ErrFenced ErrFenced no

Status only moves forward. WriterStopped is one-way: there is no reopening. Storage errors never end a writer; only Close and losing the fence do.

State returns everything in one consistent snapshot:

type WriterState struct {
    Epoch             uint64       // the writer's fence epoch, as Epoch returns
    Status            WriterStatus // WriterOpen, WriterStopped, WriterClosed, WriterFenced
    Accepted          uint64       // highest sequence handed out
    Committed         uint64       // highest sequence in object storage
    PendingMemtables  int          // memtables not yet committed, the active one included
    PendingBytes      int64        // their approximate in-memory size, before compression
    OldestUncommitted time.Time    // when the oldest uncommitted write arrived; zero if none
}

Use it for readiness probes (stop routing writes once the status is not WriterOpen), to show drain progress, and to slow producers before PendingMemtables reaches MaxPendingMemtables. It can be out of date as soon as it returns.

Failures and retries

One committer. A single goroutine inside the writer does every commit: background flushes, Flush, Drain, Close, and applying maintenance commands. Flush asks it for a commit and waits. Your context bounds only the wait, not the commit: a Flush that times out or is cancelled returns at once, and the commit carries on.

A failed commit is retried, never dropped. The writes stay queued. Background flushes retry with a delay that doubles from the flush interval up to 30 seconds. A failed Flush returns its error; calling it again retries at once. Every attempt first checks whether the previous one landed before its response was lost, so a commit never lands twice.

Every attempt has a deadline of 30 seconds plus one second per MiB to upload, so a hung storage request costs one attempt, not the writer. Each attempt in a row that times out doubles the next one's deadline, up to 10 minutes, so a slow link still gets a commit through; a successful commit resets it. A timed-out attempt is reported, and returned by a Flush waiting on it, as ErrCommitTimeout. That error does not wrap context.DeadlineExceeded: it is the writer's deadline, not yours.

Backpressure. While commits keep failing, writes pile up in memory. Once MaxPendingMemtables frozen memtables are waiting, a write that needs another one returns ErrBackpressure without being accepted. Retry after a delay, or call Flush.

Reporting. OnFlushError receives every commit and maintenance failure, such as an expired credential, an unavailable bucket or a timed-out attempt: the first failure of a run, then at most once a minute while failures continue. Commit failures and maintenance failures are separate runs, and each ends, with a log line, at its next success. Without OnFlushError, the writer logs a warning instead. Your own cancellation or deadline in Flush is returned to you, not reported.

OnFlushError runs on its own goroutine. It may call Close, and it may run after Close returns, so it must not use anything you tear down on close.

Some failures need a person. Missing permissions, a deleted bucket, a manifest entry the storage rejects, or ErrCommitIndeterminate will not fix themselves. The writer still never gives up: it retries every 30 seconds, and Put keeps succeeding until ErrBackpressure. Alert on OnFlushError, or on isledb_writer_oldest_uncommitted_timestamp_seconds (see metrics), fix the cause, or close the writer.

Maintenance commands are applied by the same committer, in order with data commits. A failure to apply one is reported and retried; it never holds up data commits. See Run maintenance.

Shutting down

Drain stops new writes and commits everything accepted, retrying while there is time; Close then finishes the writer:

func shutdownWriter(ctx context.Context, w *isledb.Writer) error {
    // 1. Take no more writes, and commit every accepted one. Put and Delete
    //    now return ErrWritesStopped; failed attempts are retried until ctx.
    drainErr := w.Drain(ctx)

    // 2. Finish the writer: one attempt, then everything it runs stops.
    closeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()
    return errors.Join(drainErr, w.Close(closeCtx))
}

DB.Close is cleanup, not the durability step. It closes a writer still open with a fixed 30-second deadline and no retry, and returns the writer's error. That is enough when nothing is pending; otherwise call Drain and Writer.Close yourself first.

To keep a crash cheap, set Flush.Interval so at most one interval of writes is unflushed.

Writer options

type WriterOptions struct {
    OwnerID      string
    Memtable     WriterMemtableOptions
    Flush        WriterFlushOptions
    Maintenance  WriterMaintenanceOptions
    Values       ValueOptions
    OnFlushError func(error)
    Metrics      *WriterMetrics
}

type WriterMemtableOptions struct {
    TargetBytes         int64
    MaxPendingMemtables int
}

type WriterFlushOptions struct {
    Interval time.Duration
}

type WriterMaintenanceOptions struct {
    PollInterval time.Duration
}

type ValueOptions struct {
    MaxKeyBytes   int
    MaxValueBytes int64
}

func DefaultWriterOptions() WriterOptions
Option Default Meaning
OwnerID generated Stable identity stored in the writer fence
Memtable.TargetBytes 16 MiB Size at which the active memtable is frozen and queued
Memtable.MaxPendingMemtables 4 Frozen memtables waiting to commit before ErrBackpressure
Flush.Interval 1 second Background commit cadence; zero turns it off
Maintenance.PollInterval 1 second How often the writer checks for maintenance commands
Values.MaxKeyBytes 64 KiB Largest accepted key
Values.MaxValueBytes 16 MiB Largest accepted value
OnFlushError nil Callback for commit and maintenance failures; see Failures and retries
Metrics nil Optional Prometheus observations

Flush.Interval is the one option where zero does not mean "default": it disables background flushing. Start from DefaultWriterOptions when you want every default.

Read data

type ReaderOpenOptions struct {
    CacheDir       string
    DiskCacheSize  int64
    BlockCacheSize int64
    BloomCacheSize int64
    Views          ReaderViewPolicy
    ReadCache      ReadCacheOptions
    Metrics        *ReaderMetrics
}

type ReaderViewPolicy struct {
    RefreshAfter time.Duration
    Manual       bool
    MaxLag       time.Duration
}

type ReadCacheOptions struct {
    URL           string
    Token         string
    HTTPClient    *http.Client
    Timeout       time.Duration
    MaxCurrentAge time.Duration
}

func DefaultReaderOpenOptions(cacheDir string) ReaderOpenOptions

CacheDir is required, and only one live reader process may use it. Zero sizes select the defaults; negative sizes are rejected.

Option Default Meaning
DiskCacheSize 8 GiB Bytes kept on disk: SST metadata, Bloom filters and SST data. Kept across restarts; the reader never deletes stale SSTs from it on its own (see PruneDiskCache under Prefetch SSTs to disk)
BlockCacheSize 256 MiB Decoded SST blocks kept in memory
BloomCacheSize 64 MiB Parsed Bloom filters kept in memory
Views.RefreshAfter 1 minute How often the view is refreshed in the background (at least 1 second)
Views.Manual false Leave publishing newer views to the application; see Prepare views before reads switch to them
Views.MaxLag 5 minutes, or a quarter of MaxPinnedViewAge if shorter In manual mode, how long the published view may stay outdated before the reader publishes the newest view itself. A value set explicitly must be below half the store's MaxPinnedViewAge, or open fails with ErrInvalidReaderOptions; if the store's first writer later sets a shorter age, half of it is used and a warning logged once
ReadCache.URL empty A regional read cache (isledb cache) to read SSTs and manifest pages and snapshots through before storage; empty reads storage directly
ReadCache.Token empty Sent as Authorization: Bearer <token>
ReadCache.HTTPClient nil For TLS, client certificates or a custom transport; nil selects a client with connection reuse
ReadCache.Timeout 10 seconds How long a read waits for the cache before reading storage instead
ReadCache.MaxCurrentAge 5 seconds The oldest copy of CURRENT a refresh takes from the cache
Metrics nil Optional Prometheus observations

With a read cache, every read of an SST, a manifest page or a manifest snapshot is sent to it first: a read the cache cannot answer, for any reason (down, slow, refused, missing object), is read from storage instead, and everything read is verified as it is from storage, so the cache can make reads slower but never wrong. A scan's reads past the first 64 KiB of each SST ask the cache not to keep what it reads. A cache that stops answering costs a few reads one timeout each, then is bypassed: after 3 timeouts or connection failures in a row, reads go straight to storage for 5 seconds, then one read tries the cache again, and the first answer from it ends the bypass. Refusals and size mismatches do not cause a bypass; the cache is answering, and the next read would fail the same way.

Damaged bytes from the cache do not fail reads. A manifest page or snapshot, a small SST read whole, or a Bloom filter that fails its checksum is read again from storage. Any other part of an SST is checked later, by its block checksums; when that finds damage, the reader reads that SST from storage for the next ten minutes, and its retry succeeds.

ReaderMetrics.ReadCacheReads counts reads the cache answered and ReadCacheFallbacks those that went to storage, by reason: timeout, unavailable, not_found, size_mismatch, refused, invalid, bypassed while the cache is skipped, checksum_mismatch for bytes from the cache that failed their checksum, or after_damage for reads of an SST found damaged recently. A steady count of the last two points at damaged chunks on the cache's disk. ReadCacheCurrent counts refreshes that asked the cache for CURRENT, by outcome: relayed, or too_old, behind or failed, which read storage instead; ReadCacheCurrentAge is the age of the last copy the cache gave; ReadCacheDoorbells counts long-poll answers: rang, quiet or error. A refused read or a size mismatch points at a misconfiguration and is also logged, at most once a minute; the start and end of a bypass are logged too. A bad URL fails OpenReader with ErrInvalidReaderOptions.

Refreshes through the cache. A refresh reads CURRENT from the cache's copy, which the cache reads from storage twice a second. The copy comes with its age, and the view's lifetime is counted from when the cache read it from storage, never from when the reader got it. A copy older than MaxCurrentAge, from a cache that cannot reach storage, or behind the view the reader already has, is not taken: the refresh reads storage instead. Refresh always reads storage, so it sees every commit made before it was called. Opening a reader reads storage too. Because the age is subtracted, a reader that refreshes through the cache shows a view loaded a little earlier, by the copy's age, than one that reads storage: view_loaded_timestamp sits behind on cached readers by the copy's age, normally under a second, by design.

The reader also holds a long poll on the cache, which answers as soon as the cache sees a new commit; the reader then refreshes as RequestRefresh does. New commits reach readers within about half a second, with RefreshAfter left as a safety net that can be set long. If the long poll fails, the reader retries it after a random wait of up to a few seconds, growing to 30 seconds while it keeps failing; refreshes on RefreshAfter go on regardless.

func (r *Reader) Get(ctx context.Context, key []byte) ([]byte, bool, error)
func (r *Reader) Scan(ctx context.Context, minKey, maxKey []byte) ([]KV, error)
func (r *Reader) ScanLimit(ctx context.Context, minKey, maxKey []byte, limit int) ([]KV, error)
func (r *Reader) NewIterator(ctx context.Context, opts IteratorOptions) (*Iterator, error)
func (r *Reader) Snapshot(ctx context.Context) (*Snapshot, error)
func (r *Reader) BootstrapView(ctx context.Context) (*BootstrapView, error)
func (r *Reader) ChangeHead(ctx context.Context) (ChangeCursor, error)
func (r *Reader) Prefetch(ctx context.Context, opts PrefetchOptions) (PrefetchStats, error)
func (r *Reader) PruneDiskCache(ctx context.Context) (int, error)
func (r *Reader) Refresh(ctx context.Context) error
func (r *Reader) RequestRefresh()
func (r *Reader) ViewPosition() ViewPosition
func (r *Reader) NextView(ctx context.Context) (*NextView, error)
func (r *Reader) DiskCacheStats() DiskCacheStats
func (r *Reader) BlockCacheStats() CacheStats
func (r *Reader) OpenSSTCacheStats() CacheStats
func (r *Reader) BloomCacheStats() CacheStats
func (r *Reader) ManifestPageCacheStats() CacheStats
func (r *Reader) Close() error

type KV struct {
    Key   []byte
    Value []byte
}

Iterate without loading the whole range

iter, err := reader.NewIterator(ctx, isledb.IteratorOptions{
    MinKey: []byte("user:"), // inclusive; nil means the beginning
    MaxKey: []byte("user;"), // exclusive; nil means the end
})
if err != nil {
    return err
}
defer func() { _ = iter.Close() }()

for iter.Next() {
    key, value := iter.Key(), iter.Value()
    _, _ = key, value
}
if err := iter.Err(); err != nil {
    return err
}
func (it *Iterator) Next() bool
func (it *Iterator) SeekGE(target []byte) bool
func (it *Iterator) Key() []byte
func (it *Iterator) Value() []byte
func (it *Iterator) Valid() bool
func (it *Iterator) Err() error
func (it *Iterator) Close() error

Consistent snapshots

A snapshot pins one loaded view. It does not move when its reader refreshes.

type Version struct { /* opaque */ }

func (v Version) String() string
func (v Version) IsZero() bool

func (s *Snapshot) Version() Version
func (s *Snapshot) ViewPosition() ViewPosition
func (s *Snapshot) Get(ctx context.Context, key []byte) ([]byte, bool, error)
func (s *Snapshot) ScanLimit(ctx context.Context, minKey, maxKey []byte, limit int) ([]KV, error)
func (s *Snapshot) NewIterator(ctx context.Context, opts IteratorOptions) (*Iterator, error)
func (s *Snapshot) Close() error

ViewPosition is the position of the view the snapshot reads, fixed when it was taken, unlike Reader.ViewPosition, which moves with refreshes. Report it beside a read to say exactly which view answered: a write whose WaitCommitted position is at or below it is included.

A snapshot and its iterators share the view's fixed deadline (MaxPinnedViewAge after it was loaded); opening another handle does not extend it. Past it, operations return ErrSnapshotExpired, ErrIteratorExpired or ErrReadViewExpired: take a new snapshot rather than retrying. Closing the reader invalidates its snapshots and iterators.

A snapshot of an older view keeps reading correctly after the reader moves on: object storage keeps its SSTs for MaxPinnedViewAge. Once a refresh retires those SSTs, the reader deletes them from its disk cache, so the snapshot may fetch them again from object storage. A long BootstrapView load running across a compaction is where that shows.

Load state, then follow the change feed

BootstrapView gives you a snapshot and the exact change-feed position that follows it, both from the same manifest:

type BootstrapView struct {
    Snapshot *Snapshot
    Cursor   ChangeCursor
    Version  Version
}

The snapshot holds every write committed before Cursor; Cursor is the first feed position the snapshot does not contain. Version matches Snapshot.Version() and is handy in checkpoint metadata. Build a local copy from the snapshot, save Cursor with it, and then consume only later changes:

view, err := reader.BootstrapView(ctx)
if err != nil {
    return err
}
defer view.Snapshot.Close()

iterator, err := view.Snapshot.NewIterator(ctx, isledb.IteratorOptions{})
if err != nil {
    return err
}
defer iterator.Close()

for iterator.Next() {
    if err := materialize(iterator.Key(), iterator.Value()); err != nil {
        return err
    }
}
if err := iterator.Err(); err != nil {
    return err
}

if err := saveCheckpointCursor(view.Cursor.String()); err != nil {
    return err
}

Prefetch SSTs to disk

type KeyRange struct {
    Min []byte // inclusive; nil means the beginning
    Max []byte // exclusive; nil means the end
}

func PrefixRange(prefix []byte) KeyRange

type PrefetchOptions struct {
    Range       KeyRange
    All         bool
    MaxSSTs     int
    MaxBytes    int64
    Concurrency int
}

type PrefetchStats struct {
    MatchedSSTs int
    CachedSSTs  int
    SkippedSSTs int
    BytesRead   int64
}
stats, err := reader.Prefetch(ctx, isledb.PrefetchOptions{
    Range:       isledb.PrefixRange([]byte("user:")),
    MaxBytes:    256 << 20,
    Concurrency: 4,
})

Prefetch downloads the selected SSTs (metadata, Bloom filter and data) into the disk cache, fetching only what is missing and sharing requests with concurrent reads.

reader, err := db.OpenReader(ctx, opts)
if err != nil {
    return err
}
if _, err := reader.PruneDiskCache(ctx); err != nil {
    return err
}
stats, err := reader.Prefetch(ctx, isledb.PrefetchOptions{All: true})

Freshness and outages

A reader answers from a loaded view of the manifest. The view is refreshed in the background every Views.RefreshAfter; reads never start a refresh or wait for one.

Situation What reads do
Refreshes succeed The view is never older than RefreshAfter.
A refresh fails, or takes over 30 seconds Reads keep using the loaded view, and count in stale_reads_total. The next refresh is tried about 30 seconds later (15 to 45, or RefreshAfter if that is shorter). A warning is logged at most once a minute, and an info line when refreshes recover.
The view is older than MaxPinnedViewAge A read waits for a refresh and fails if it fails, or with ErrReadViewExpired if storage's CURRENT is behind the view. Background refreshes keep trying, so reads work again as soon as storage answers.

So during an outage reads can be up to MaxPinnedViewAge old, and they stay correct: every SST a view names is kept until the view expires.

Spread-out refreshes. Each reader picks a random offset within RefreshAfter when it opens and refreshes exactly once per interval at that offset. Readers started together by a deploy stay spread evenly; neither a forced Refresh nor a failure moves the schedule, and retries after an outage stay on the reader's own offset. N readers read CURRENT a steady N / RefreshAfter times a second. The one exception is opening: each reader loads its first view as it opens.

An idle reader still reads CURRENT once per interval.

Refresh always reloads, waits and returns any failure, so a caller that needs the latest commits learns when it cannot have them. It reflects every commit made before the call, even when it joins a refresh already in progress.

RequestRefresh starts the background refresh now instead of at the next interval and returns at once. Use it when something tells you the data changed, such as a message from the writer, so readers pick up the change without polling more often. It runs the refresh the interval would run, so in manual mode the newer view is held for NextView and warmed before it is published. Requests within a second of the last are merged into one refresh at the end of that second, never dropped. Failures are logged and counted, as for any background refresh. Keep RefreshAfter as the safety net for a missed request.

Refresh RequestRefresh
Caller waits yes no
In manual mode publishes at once holds the view for NextView
Errors returned logged and counted
Use for read-your-writes "the data changed" notifications

Internals of how a reader fetches and caches data are in the appendix.

Prepare views before reads switch to them

A compaction rewrites data into new SSTs. When reads switch to a view naming them, the first lookups of hot keys each wait for object storage. In manual mode the application takes each newer view the reader loads, prepares it, for example by downloading its new SSTs, and only then switches reads to it.

type ViewPosition uint64

type SSTInfo struct {
    ID             string
    Level          int
    Size           int64 // in the manifest
    BloomSize      int64 // stored after Size in the same object
    MinKey, MaxKey []byte
}

func (v *NextView) Previous() ViewPosition
func (v *NextView) Next() ViewPosition
func (v *NextView) Version() Version
func (v *NextView) Added() []SSTInfo
func (v *NextView) Removed() []SSTInfo
func (v *NextView) Snapshot() (*Snapshot, error)
func (v *NextView) Prefetch(ctx context.Context, opts PrefetchOptions) (PrefetchStats, error)
func (v *NextView) Publish() error
func (v *NextView) Discard()
opts := isledb.DefaultReaderOpenOptions("/var/cache/isledb")
opts.Views.Manual = true
reader, err := db.OpenReader(ctx, opts)
if err != nil {
    return err
}
for {
    next, err := reader.NextView(ctx) // waits for the reader's next newer load
    if err != nil {
        return err // ctx ended or the reader closed
    }
    if _, err := next.Prefetch(ctx, isledb.PrefetchOptions{All: true}); err != nil {
        log.Printf("warming view %d: %v", next.Next(), err) // still correct, only colder
    }
    switch err := next.Publish(); {
    case err == nil, errors.Is(err, isledb.ErrViewChanged), errors.Is(err, isledb.ErrNextViewExpired):
        // Published, or another view was; take the next one.
    default:
        return err
    }
}

Change feed

The change feed is optional. Open the database with it, then open the writer: the writer turns it on.

db, err := isledb.Open(ctx, bucketURL, isledb.DBOptions{
    Prefix: "accounts",
    ChangeFeed: &isledb.ChangeFeedOptions{
        Payload: isledb.ChangeFeedFullValues,
    },
})
type ChangeFeedPayload uint8

const (
    ChangeFeedKeysOnly ChangeFeedPayload = iota + 1
    ChangeFeedFullValues
)

type ChangeFeedOptions struct {
    Payload ChangeFeedPayload
}

Only a writer turns the feed on: OpenWriter, on a DB opened with ChangeFeed, turns it on once it holds the database, before it takes any write. Opening a DB with ChangeFeed and no writer, for a reader, a change reader or maintenance, only checks the mode; it doesn't turn the feed on, so a running writer without the feed keeps committing.

The payload mode is stored when the feed is turned on and cannot change. Reopening with a different mode returns ErrChangeFeedPayloadMismatch; reopening with ChangeFeed == nil adopts the stored mode. Turning the feed on for an existing database starts it at the current head; older history is not added.

Types

type ChangeOperation uint8

const (
    ChangePut ChangeOperation = iota + 1
    ChangeDelete
)

type Change struct {
    Sequence  uint64
    Revision  ViewPosition
    Operation ChangeOperation
    Key       []byte
    Value     []byte
    HasValue  bool
    ExpiresAt time.Time
}

type ChangeCursor struct { /* opaque */ }

func ParseChangeCursor(value string) (ChangeCursor, error)
func (c ChangeCursor) String() string
func (c ChangeCursor) IsZero() bool
func (c ChangeCursor) Compare(other ChangeCursor) int
func (c ChangeCursor) MarshalText() ([]byte, error)
func (c *ChangeCursor) UnmarshalText(text []byte) error

type ChangeBounds struct {
    Oldest  ChangeCursor
    Head    ChangeCursor
    Payload ChangeFeedPayload
}

type ChangePage struct {
    Changes []Change
    Next    ChangeCursor
    Head    ChangeCursor
}

func (p ChangePage) CaughtUp() bool

In keys-only mode, a PUT has HasValue == false and a nil Value. In full-values mode, HasValue tells an empty value apart from a missing one. Deletes never carry a value.

Revision is the lowest view position that holds the change, that of the commit holding it; changes committed together share it. A Reader whose ViewPosition is at or past it sees the change. A write's WaitCommitted returns this position or a later one. In keys-only mode, read the key from a reader at or past Revision: one behind it can still return the old value.

Read pages

type ChangeReadOptions struct {
    MaxChanges int
    MaxBytes   int64
}

func DefaultChangeReadOptions() ChangeReadOptions

func (r *ChangeReader) Bounds(ctx context.Context) (ChangeBounds, error)
func (r *ChangeReader) Read(ctx context.Context, from ChangeCursor, opts ChangeReadOptions) (ChangePage, error)
func (r *ChangeReader) WaitForChanges(ctx context.Context, from ChangeCursor) error
func (r *ChangeReader) LoadedHead() ChangeCursor
func (r *ChangeReader) Close() error

Read the feed through a read cache

type ChangeReaderOptions struct {
    ReadCache ReadCacheOptions
    Metrics   *ReaderMetrics
}

OpenChangeReader takes at most one ChangeReaderOptions. With ReadCache.URL set, a change reader reads through the regional read cache (see ReadCacheOptions under Read data) before storage:

Any read the cache cannot answer is read from storage, so the feed returns the same changes either way. Metrics counts the cache's reads and fallbacks (ReadCacheReads, ReadCacheFallbacks), its CURRENT copies (ReadCacheCurrent, ReadCacheCurrentAge) and the long poll's answers (ReadCacheDoorbells). A bad URL fails OpenChangeReader with ErrInvalidReaderOptions. Each change reader with a read cache keeps its own in-memory cache of manifest pages, of up to 1,024 pages, as each Reader does.

Choose where to start

A zero cursor means "no saved position", not "start at the latest change". Pick the starting policy explicitly:

bounds, err := reader.Bounds(ctx)
if err != nil {
    return err
}

// Replay every change still retained.
replayCursor := bounds.Oldest

// Skip existing history; consume only changes published after Bounds.
tailCursor := bounds.Head

Oldest is the first retained position. Head is just past everything Bounds saw, so a first read from it is usually empty and caught up. (A zero cursor passed to Read starts at Oldest, but calling Bounds makes the choice explicit.)

Save the starting cursor before you poll. Otherwise a process that picks Head, crashes before saving it, and restarts would pick a newer head and silently skip changes. After that, save page.Next only once the whole page has been applied.

func drainChanges(
    ctx context.Context,
    reader *isledb.ChangeReader,
    savedCursor string,
    startAtHead bool,
    apply func(isledb.Change) error,
    saveCursor func(string) error,
) error {
    cursor, err := isledb.ParseChangeCursor(savedCursor)
    if err != nil {
        return err
    }

    if cursor.IsZero() {
        bounds, err := reader.Bounds(ctx)
        if err != nil {
            return err
        }
        if startAtHead {
            cursor = bounds.Head
        } else {
            cursor = bounds.Oldest
        }
        if err := saveCursor(cursor.String()); err != nil {
            return err
        }
    }

    options := isledb.DefaultChangeReadOptions()
    for {
        page, err := reader.Read(ctx, cursor, options)
        if err != nil {
            return err
        }
        for _, change := range page.Changes {
            if err := apply(change); err != nil {
                return err
            }
        }
        if err := saveCursor(page.Next.String()); err != nil {
            return err
        }
        cursor = page.Next
        if page.CaughtUp() {
            return nil
        }
    }
}

When a cursor expires

If retention deletes changes past a saved cursor, Read refreshes its view and returns ErrChangeCursorExpired: there is a gap between your position and the oldest retained one. You can:

Run maintenance

Maintenance compacts, checkpoints, applies change-feed retention and deletes retired objects. It is designed to run in its own process, against the same bucket and prefix:

writer process       -> buffers writes and commits them
reader processes     -> load views and read independently
maintenance process  -> prepares compaction, checkpoints and retention
reclamation workers  -> delete retired objects at their own bounded pace

How its work is published. Maintenance never edits the manifest itself. It does the heavy work (for example, writing compacted SSTs), then stages a command in a small mailbox object, maintenance/HEAD. The active writer checks the mailbox in the background every WriterOptions.Maintenance.PollInterval (one second by default, each check bounded to 5 seconds) and applies the command with its next commit, in order with data commits. Flush never waits on the mailbox, so a slow or hung mailbox cannot delay commits. Close checks it once more so a command staged just before shutdown is not left behind, and a command superseded by a later one is skipped. Without a flush interval, a fetched command is applied at the next Flush, Drain or Close.

Deletion runs separately. SSTs, change batches, manifest snapshots and manifest pages are deleted in independent lanes at their own pace, so slow deletes never block compaction.

func (m *Maintenance) Run(ctx context.Context) error
func (m *Maintenance) RunOnce(ctx context.Context) (MaintenanceStats, error)
func (m *Maintenance) Shape(ctx context.Context) (LSMShape, error)
func (m *Maintenance) Close(ctx context.Context) error

Run continues until its context ends, Close is called, or another process takes over maintenance. Close with a fresh context, since the run context is usually already cancelled:

func runMaintenance(ctx context.Context, db *isledb.DB) error {
    maintenance, err := db.OpenMaintenance(ctx, isledb.DefaultMaintenanceOptions())
    if err != nil {
        return err
    }

    runErr := maintenance.Run(ctx)

    closeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    closeErr := maintenance.Close(closeCtx)

    if errors.Is(runErr, context.Canceled) {
        runErr = nil
    }
    return errors.Join(runErr, closeErr)
}

Shape reports the database's files by level now: how much newly saved data waits in L0 and how deep it is, and each level's size against its target. It reads only what changed since its last call, and it doesn't wait for a compaction in progress, so it can be called every few seconds to watch whether compaction keeps up. The types are in the appendix.

For a scheduled job, call RunOnce instead:

stats, err := maintenance.RunOnce(ctx)

RunOnce makes one control pass and one bounded pass of each deletion lane. If it returns MaintenanceWaitingForWriter, it has staged a command that the writer must apply; the next run picks up from there. One scheduled run is a bounded step of progress, not a promise to clear the whole backlog.

Maintenance options

type MaintenanceOptions struct {
    IdleInterval        time.Duration
    SSTCompaction       SSTCompactionOptions
    ManifestCheckpoint  ManifestCheckpointOptions
    ChangeFeedRetention *ChangeFeedRetentionOptions
    Reclamation         ReclamationOptions
    OnCycle             func(MaintenanceStats)
    OnReclamationCycle  func(ReclamationCycleStats)
    OnError             func(error)
}

type SSTCompactionOptions struct {
    ReadConcurrency   int
    ScratchDir        string
    L0TriggerSSTs     int
    BaseLevelBytes    int64
    LevelGrowthFactor int
    TargetSSTBytes    int64
    L0ToBaseRatio     float64
    L0MaxDepth        int
    L0MaxAge          time.Duration
}

type ManifestCheckpointOptions struct {
    TargetReplayPages uint64
    TargetReplayBytes uint64
}

type ReclamationOptions struct {
    MaxConcurrentDeletes int
    SST                  DeleterOptions
    ChangeFeed           DeleterOptions
    Manifest             ManifestDeleterOptions
}

type DeleterOptions struct {
    PollInterval      time.Duration
    MaxObjectsPerPass int
}

type ManifestDeleterOptions struct {
    DeleterOptions
    AuditInterval time.Duration
}

func DefaultMaintenanceOptions() MaintenanceOptions
Option Default
IdleInterval 5 seconds
SSTCompaction.ReadConcurrency 4
SSTCompaction.ScratchDir user cache directory, falling back to a per-user temporary directory
SSTCompaction.L0TriggerSSTs 8
SSTCompaction.BaseLevelBytes 512 MiB
SSTCompaction.LevelGrowthFactor 8
SSTCompaction.TargetSSTBytes 64 MiB
SSTCompaction.L0ToBaseRatio 1
SSTCompaction.L0MaxDepth 16 L0 files
SSTCompaction.L0MaxAge 30 minutes
ManifestCheckpoint.TargetReplayPages 64 pages
ManifestCheckpoint.TargetReplayBytes 32 MiB
ChangeFeedRetention nil: history kept forever
Reclamation.MaxConcurrentDeletes 4, shared by all lanes
Deletion lane Poll interval Most objects per pass
SST 1 second 128
Change feed 5 seconds 128
Manifest snapshots and pages 1 minute 128

The manifest lane also audits for orphaned objects once an hour (AuditInterval). MaxObjectsPerPass bounds normal work, but one SST retirement plan, already bounded, may finish in a single pass even if it goes over.

Scratch space. Compaction streams its inputs to ScratchDir, so a job is not limited by memory. Each store, role and fence epoch gets a private session directory below it. A clean close removes the session; a later epoch tries to remove abandoned ones, and a failed cleanup never stops maintenance from opening. IsleDB never deletes the configured directory itself or anything below it that it did not create.

How L0 is compacted. The writer saves a small L0 file at each flush. Once L0 holds L0TriggerSSTs files, each maintenance cycle does one of two things:

With random keys, L0 overlaps nearly all of L1, so L0ToBaseRatio rarely triggers and L0MaxDepth decides when L0 is merged into L1.

While L0 has work and is changing, maintenance runs a cycle every 2 seconds (5 seconds on Cloud Storage) instead of every IdleInterval. SSTCompactionStats.L0Merges counts the merges within L0.

The first merge within L0 moves the database to layout version 3. Releases before this one refuse to open it from then on, so upgrade every process that opens the database before running this release's maintenance.

Compacted SSTs are encoded with DBOptions.SSTOutput.Compacted. The compaction planner's limits are described in the appendix.

OnCycle receives statistics for each control pass and OnReclamationCycle for each deletion pass; the types are in the appendix.

Change-feed retention

Retention is off until ChangeFeedRetention is set:

type ChangeFeedRetentionOptions struct {
    RetainFor time.Duration
}

func DefaultChangeFeedRetentionOptions() ChangeFeedRetentionOptions

options := isledb.DefaultMaintenanceOptions()
retention := isledb.DefaultChangeFeedRetentionOptions() // seven days
retention.RetainFor = 15 * 24 * time.Hour
options.ChangeFeedRetention = &retention

RetainFor is a minimum age, not an exact deletion time: publishing, view-safety rules and paced deletion can keep objects longer.

Prometheus metrics

func DefaultWriterMetrics(constLabels prometheus.Labels) *WriterMetrics
func DefaultReaderMetrics(constLabels prometheus.Labels) *ReaderMetrics

Set the result as WriterOptions.Metrics or ReaderOpenOptions.Metrics. The constructors create the collectors but do not register them; register them with your prometheus.Registerer.

Writer metrics cover puts, deletes, backpressure, flush counts, errors, latency and bytes, plus:

time() - (isledb_writer_oldest_uncommitted_timestamp_seconds > 0) > 120

Change-feed lag. Every Change carries the writer's sequence, so a consumer that exports its last applied Change.Sequence gets its lag from Prometheus with no extra reads. With several databases, label each one's metrics through constLabels and match on it:

clamp_min(
  max by (db) (isledb_writer_committed_sequence)
    - on(db) group_right isledb_follower_applied_sequence,
  0)

clamp_min hides brief negative values caused by scrape timing. Lag counts changes committed but not yet applied; writes still buffered in the writer are not committed and not counted.

Reader metrics cover refreshes, point reads, scans and SST range reads, plus:

In manual mode:

time() - (isledb_reader_view_outdated_since_timestamp_seconds > 0) > 60

Error reference

Check sentinel errors with errors.Is:

if errors.Is(err, isledb.ErrBackpressure) {
    // Retry according to the application's admission policy.
}

Storage and context errors can also be returned, wrapped. Log the complete error.

Database and configuration

Error Meaning
ErrInvalidDBOptions Invalid store policy, feed mode, SST encoding, or OpenBucket input
ErrWriterAlreadyOpen This DB already has a writer
ErrReaderAlreadyOpen This DB already has a KV reader
ErrChangeFeedDisabled The change feed is not enabled
ErrChangeFeedPayloadMismatch Requested payload differs from the stored mode
ErrStorePolicyMismatch Writer policy differs from the stored store policy
ErrCommitIndeterminate An uncertain commit can no longer be proven, because the manifest entries that would show it were retired

Writer

Error Meaning
ErrBackpressure Too many memtables waiting to commit; the write was not accepted
ErrInvalidMutation Empty or oversized key, oversized value, negative TTL, or an empty batch
ErrBatchTooLarge Apply: every mutation is valid but the batch does not fit one memtable; split it
ErrInvalidWriterOptions Invalid limits, interval, identity, or memory settings
ErrWritesStopped A write after StopWrites, Drain or while Close runs; accepted writes are still committing
ErrWriterClosed The writer was closed
ErrCommitTimeout A commit attempt ran out of its own deadline; the writes stay queued and are retried
ErrFenced Another writer took over; this one commits nothing more
ErrNilContext A nil context was passed

Reader and snapshots

Error Meaning
ErrInvalidReaderOptions Negative or sub-second refresh interval, negative MaxLag, or MaxLag not below half the store's MaxPinnedViewAge
ErrReaderClosed The reader was closed
ErrNotManual NextView on a reader not in manual mode
ErrViewChanged Publish found another view published since the view was handed out
ErrNextViewExpired The next view passed MaxPinnedViewAge from its load before it was published
ErrNextViewDone The next view was already published or discarded
ErrReadViewExpired The reader's view passed MaxPinnedViewAge
ErrSnapshotClosed The snapshot was closed
ErrSnapshotExpired The snapshot's view passed its deadline
ErrIteratorExpired The iterator's view passed its deadline

Change feed

Error Meaning
ErrChangeReaderClosed The change reader was closed
ErrInvalidChangeCursor Cursor text is malformed or unsupported
ErrChangeCursorExpired Retention deleted changes past the cursor
ErrInvalidChangeReadOptions Negative page limit
ErrCorruptChangeFeed Feed metadata in the manifest is inconsistent
ErrCorruptChangeBatch A change batch's index, block or checksum is invalid

Maintenance

Error Meaning
ErrMaintenanceAlreadyOpen This DB already has a maintenance handle
ErrMaintenanceClosed Maintenance was closed
ErrMaintenanceRunning Run or RunOnce is already running
ErrInvalidMaintenanceOptions Invalid interval, concurrency, or work bound

Appendix: how a reader reads SSTs

Every SST is read from object storage by byte range, through four caches:

Cache Holds Bounded by
Open SSTs Parsed SST readers, up to 1,024, least recently used out first count
Block cache Decoded index and data blocks BlockCacheSize
Bloom cache Parsed Bloom filters BloomCacheSize
Disk cache, under CacheDir SST metadata and Bloom filters; whole small SSTs and 128 KiB chunks of larger ones. One budget; data is evicted first, never metadata to make room for data DiskCacheSize

Fetching.

What gets cached. A point lookup caches the blocks it reads, in memory and on disk. A scan, and each iterator seek, caches only its first 64 KiB of keys and values in each SST, so short reads (a page, a prefix, a seek) are warm when repeated. Reading further uses buffers of its own, still drawing on what is cached: a long scan adds at most about 64 KiB per SST to memory and two chunks to disk, and never evicts what lookups reuse.

Speed and eviction. A read of an already-open SST parses no metadata; a warm lookup takes about 1 µs. Each cache evicts least recently used first. Nothing is dropped when an SST leaves the manifest, so a snapshot still reading it stays warm. A cached part that proves damaged is dropped and fetched again; a lookup that meets damaged cache bytes retries once from object storage instead of failing.

The disk cache survives restarts and holds no files open: each read opens, reads and closes, so the budget is exact.

Block cache memory. The block cache is Pebble's. With cgo it lives outside the Go heap: it counts toward resident memory but not toward GOMEMLIMIT or heap profiles. Without cgo (CGO_ENABLED=0) it is ordinary Go heap, which the garbage collector lets grow to about twice the live heap under the default GOGC; set GOMEMLIMIT to bound the process.

Cache statistics

type CacheStats struct {
    Hits        int64
    Misses      int64
    Bytes       int64
    MaxBytes    int64
    EntryCount  int
    MaxEntries  int
    Evictions   int64
    Corruptions int64
    Bypasses    int64
    Failures    int64
}

type DiskCacheStats struct {
    Meta     CacheStats // SST metadata and Bloom filters
    Data     CacheStats // whole small SSTs and chunks of larger SSTs' data
    SSTDrops int64      // reads that failed on damaged bytes and dropped the SST
}

Appendix: SST output policy

How new SST files are encoded is a runtime setting. It applies to files written from now on and is not stored in the manifest; every file describes its own encoding, so readers handle a mix.

type SSTOutputOptions struct {
    L0        SSTEncodingOptions
    Compacted SSTEncodingOptions
}

type SSTEncodingOptions struct {
    Compression     string
    BlockBytes      int
    BloomBitsPerKey int
}

func DefaultSSTOutputOptions() SSTOutputOptions

Compression is "none", "snappy" or "zstd". Zero fields select the defaults:

SST class Compression Data block target Bloom bits/key
Writer output (L0) Snappy 4 KiB 10
Compacted output Snappy 4 KiB 10

The writer and maintenance can use different settings:

dbOptions := isledb.DBOptions{
    Prefix: "accounts",
    SSTOutput: isledb.SSTOutputOptions{
        L0: isledb.SSTEncodingOptions{
            Compression:     "snappy",
            BlockBytes:      4 << 10,
            BloomBitsPerKey: 10,
        },
        Compacted: isledb.SSTEncodingOptions{
            Compression:     "zstd",
            BlockBytes:      16 << 10,
            BloomBitsPerKey: 10,
        },
    },
}

Separate writer and maintenance processes each pass their own DBOptions. Different settings are safe, though one shared configuration makes performance easier to predict.

Appendix: compaction limits

One manifest entry can remove at most 128 objects and add at most 128. This is a fixed format rule, not a tuning option. The planner picks the widest source batch whose whole overlap with the next level fits. If even one source SST overlaps too many files below it, the planner first compacts that destination level further down and retries the original move on a later cycle.

If that drain reaches the bottom of the tree, IsleDB creates one deeper level. For sorted levels this is usually a metadata-only move: the SSTs keep their IDs and bytes, and only their level changes. It is a correctness escape hatch for the 128-object limit, not normal level sizing. Doing it repeatedly can deepen reads and delay merging tombstones and overwritten values, so it is counted in SSTCompactionStats.ForcedLevelCreations, with DeepestForcedLevel recording the deepest level reached. Ordinary creation of a new bottom level by size is not counted. As with every compaction, the statistic counts a staged job; the new layout is visible only once the writer applies it.

Appendix: maintenance statistics

type MaintenanceState uint8

const (
    MaintenanceIdle MaintenanceState = iota
    MaintenanceWaitingForWriter
)

type MaintenanceTask uint8

const (
    MaintenanceTaskNone MaintenanceTask = iota
    MaintenanceTaskSSTCompaction
    MaintenanceTaskManifestCheckpoint
)

type ReclamationFamily string

const (
    ReclamationSST        ReclamationFamily = "sst"
    ReclamationChangeFeed ReclamationFamily = "change_feed"
    ReclamationManifest   ReclamationFamily = "manifest"
)

MaintenanceState, MaintenanceTask, ChangeOperation, ChangeFeedPayload and WriterStatus implement String.

type MaintenanceStats struct {
    State               MaintenanceState
    WaitingSince        time.Time
    Scheduling          MaintenanceScheduleStats
    SSTCompaction       SSTCompactionStats
    SSTCleanup          SSTCleanupStats
    ChangeFeedRetention ChangeFeedCleanupStats
    ManifestCheckpoint  ManifestCheckpointStats
    ManifestCleanup     ManifestCleanupStats
    RejectedCommands    int
    Duration            time.Duration
}

// LSMShape is what Maintenance.Shape returns.
type LSMShape struct {
    L0      LevelShape   // newly saved files; TargetBytes is 0
    L0Depth int          // the most L0 files covering one key
    Levels  []LevelShape // L1 and below, in order
}

type LevelShape struct {
    Level       uint32
    Files       int
    Bytes       int64
    TargetBytes int64 // BaseLevelBytes for L1, LevelGrowthFactor times more per level below
}

type ReclamationCycleStats struct {
    Family     ReclamationFamily
    SST        SSTCleanupStats
    ChangeFeed ChangeFeedCleanupStats
    Manifest   ManifestCleanupStats
    Duration   time.Duration
}

type MaintenanceScheduleStats struct {
    Selected              MaintenanceTask
    CompactionSourceLevel uint32
    CompactionWorkUnits   uint32
    CompactionCritical    bool
    CheckpointEligible    bool
    CheckpointUrgent      bool
    ReplayPages           uint64
    ReplayBytes           uint64
}

type SSTCompactionStats struct {
    Jobs                 int
    InputSSTs            int
    OutputSSTs           int
    ForcedLevelCreations int
    DeepestForcedLevel   uint32
    OutputBytes          int64
    MovedJobs            int
    MovedBytes           int64
    ReadBytes            int64
    L0Merges             int
    RewrittenBytes       int64
}

type SSTCleanupStats struct {
    SSTsPlanned    int
    PlansPrepared  int
    PlansScanned   int
    PlansCompleted int
    DeleteAttempts int
    SSTsDeleted    int
    DeferredPlans  int
    Failures       int
}

type ManifestCheckpointStats struct {
    Staged      bool
    ReplayPages uint64
    ReplayBytes uint64
}

type ChangeFeedCleanupStats struct {
    EntriesRetired  int
    BatchesPlanned  int
    BatchesDeleted  int
    BlockedRetained int
    FailedDeletes   int
    Duration        time.Duration
}

type ManifestCleanupStats struct {
    Snapshots ManifestSnapshotCleanupStats
    Pages     ManifestPageCleanupStats
}

type ManifestSnapshotCleanupStats struct {
    SnapshotsMarked  int
    DeleteAttempts   int
    SnapshotsDeleted int
    Protected        int
    Deferred         int
    Failures         int
    MarkersScanned   int
    MarkersCleared   int
    ObjectsScanned   int
    Duration         time.Duration
}

type ManifestPageCleanupStats struct {
    PagesMarked      int
    PagesDeleted     int
    Protected        int
    Deferred         int
    Failures         int
    MarkersScanned   int
    MarkersCleared   int
    ObjectsScanned   int
    DeleteAttempts   int
    ReachabilityGETs int
    Duration         time.Duration
}

Appendix: the blobstore package

Most applications should use isledb.Open or isledb.OpenBucket. The github.com/ankur-anand/isledb/blobstore package is for storage adapters, integration tests and operational tools.

func blobstore.Open(ctx context.Context, bucketURL, prefix string) (*blobstore.Store, error)
func blobstore.New(bucket *blob.Bucket, bucketName, prefix string) *blobstore.Store
func blobstore.NewMemory(prefix string) *blobstore.Store

Object operations:

func (s *Store) Read(ctx context.Context, key string) ([]byte, Attributes, error)
func (s *Store) ReadStream(ctx context.Context, key string) (*blob.Reader, error)
func (s *Store) ReadRange(ctx context.Context, key string, offset, length int64) ([]byte, error)
func (s *Store) ReadRangeStream(ctx context.Context, key string, offset, length int64) (*blob.Reader, error)
func (s *Store) Attributes(ctx context.Context, key string) (Attributes, error)
func (s *Store) Exists(ctx context.Context, key string) (bool, error)
func (s *Store) Write(ctx context.Context, key string, data []byte) (Attributes, error)
func (s *Store) WriteReader(ctx context.Context, key string, r io.Reader, opts *blob.WriterOptions) (Attributes, error)
func (s *Store) WriteIfMatch(ctx context.Context, key string, data []byte, etag string) (Attributes, error)
func (s *Store) WriteIfNotExist(ctx context.Context, key string, data []byte) (Attributes, error)
func (s *Store) Delete(ctx context.Context, key string) error
func (s *Store) BatchDelete(ctx context.Context, keys []string) error

Listing operations:

type ListOptions struct {
    Prefix    string
    Delimiter string
}

type ObjectInfo struct {
    Key   string
    Size  int64
    IsDir bool
}

type ListResult struct {
    Objects []ObjectInfo
}

func (s *Store) List(ctx context.Context, opts ListOptions) (*ListResult, error)
func (s *Store) NewListIterator(opts ListOptions) *ListIterator
func (it *ListIterator) Next(ctx context.Context) (ObjectInfo, error)
func (s *Store) Walk(ctx context.Context, opts ListOptions, visit func(ObjectInfo) (bool, error)) error

List loads every matching object into memory; prefer NewListIterator or Walk for large listings.

type Attributes struct {
    Size       int64
    ETag       string
    ModTime    time.Time
    Generation int64
}

type BatchDeleteError struct {
    Failed map[string]error
}