#1515·litestream

`restore -f` stops making progress permanently and logs nothing when it cannot bridge a gap

Author: bitfliqCreated Sep 10, 2026Updated Sep 10, 2026

Bug Description

A restore -f follower can stop advancing permanently while emitting no error and no warning. It stays alive, keeps polling, and keeps serving reads from a database that never changes again. A restart resumes from the -txid sidecar and stops at the same TXID. A plain litestream restore from the same replica, at the same moment, restores the database completely, so the data is reachable; the follow path just cannot reach it.

Not a duplicate of the other silent-stall reports: #1037 and #1083 were the primary-side WAL change-detection stall (fixed), and #1310 was initial sync wedging on a full disk. This is the follower after it is already running. Open PR #1385 relaxes the sibling "ahead of latest snapshot" resume guard and says it leaves the "behind the earliest snapshot" check alone, so it does not cover this either.

Environment

Litestream version:

0.5.17

Also reproduced on 0.5.14 and 0.5.16. replica.go is byte-identical on v0.5.17 and main (checked at 4ed7a308f627), and fillFollowGap is unchanged since v0.5.14.

Operating system & version: Debian bookworm-slim container, Linux x86_64

Installation method: official .deb from the GitHub release

Storage backend: S3 (MinIO in the reproduction; also seen on a hosted S3-compatible store)

Steps to Reproduce

Self-contained, no credentials, ~3 minutes: docker compose run --rm repro (the four files are inline below so they can be read before running; MinIO runs in the compose project). They are also in a gist if cloning is easier: https://gist.github.com/bitfliq/8b3c982599a7af5fc1b48420d37a7c86

bash
git clone https://gist.github.com/bitfliq/8b3c982599a7af5fc1b48420d37a7c86.git repro && cd repro && docker compose run --rm repro
  1. Replicate a WAL-mode SQLite database, writing ~24 transactions.
  2. Delete one L0 object from the replica, standing in for an upload that never landed. The script asserts L0 is contiguous first, and picks a TXID above the L1 watermark, so the hole is real. (If L1 has already absorbed that TXID, fillFollowGap bridges via L1 and the follower correctly recovers; that is the control.)
  3. Keep writing, so the frontier advances.
  4. Park a follower one TXID below the hole and run restore -f.
  5. Run a plain litestream restore from the same replica as a control.
reproduction files (4 files, self-contained)

docker-compose.yml

yaml
services:
  minio:
    image: minio/minio:latest
    command: server /data
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 3s
      timeout: 3s
      retries: 20

  createbucket:
    image: minio/mc:latest
    depends_on:
      minio: {condition: service_healthy}
    entrypoint: >
      /bin/sh -c "mc alias set local http://minio:9000 minioadmin minioadmin &&
                  mc mb --ignore-existing local/repro"

  repro:
    build:
      context: .
      args:
        LITESTREAM_VERSION: ${LITESTREAM_VERSION:-0.5.17}
    depends_on:
      createbucket: {condition: service_completed_successfully}
    environment:
      AWS_ACCESS_KEY_ID: minioadmin
      AWS_SECRET_ACCESS_KEY: minioadmin

Dockerfile

dockerfile
FROM debian:bookworm-slim
ARG LITESTREAM_VERSION=0.5.17
# Populated automatically by BuildKit (amd64 / arm64), so the reproduction runs
# on an Apple Silicon laptop as well as an x86_64 server.
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates curl sqlite3 python3 && rm -rf /var/lib/apt/lists/*
RUN set -eu; \
    case "${TARGETARCH:-amd64}" in \
      amd64) LS_ARCH=x86_64 ;; \
      arm64) LS_ARCH=arm64  ;; \
      *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
    esac; \
    curl -fsSL -o /tmp/ls.deb \
      "https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-${LITESTREAM_VERSION}-linux-${LS_ARCH}.deb"; \
    dpkg -i /tmp/ls.deb; rm /tmp/ls.deb
RUN set -eu; \
    curl -fsSL -o /usr/local/bin/mc \
      "https://dl.min.io/client/mc/release/linux-${TARGETARCH:-amd64}/mc"; \
    chmod +x /usr/local/bin/mc
COPY litestream.yml /etc/litestream.yml
COPY repro.sh /repro.sh
RUN chmod +x /repro.sh
ENTRYPOINT ["/repro.sh"]

litestream.yml

yaml
# Intervals compressed so the scenario completes in ~3 minutes.
# Defaults (L1 30s / L9 24h / l0-retention 5m) behave the same, just slower.
levels:
  - interval: 45s
  - interval: 5m
  - interval: 1h

snapshot:
  # Snapshots must track the frontier, as they do on any long-running database.
  # With only a txid-1 snapshot the follower instead hits the documented
  # "saved TXID is ahead of latest snapshot" refusal, which is a different path.
  interval: 20s
  retention: 24h

l0-retention: 300s
l0-retention-check-interval: 5s

dbs:
  - path: /data/primary/db.sqlite
    replicas:
      - type: s3
        bucket: repro
        path: db
        endpoint: http://minio:9000
        region: us-east-1
        force-path-style: true
        sync-interval: 1s

repro.sh

bash
#!/usr/bin/env bash
# Follow mode stops making progress, forever, without logging anything.
#
# Setup: one L0 object is removed from the replica, standing in for an upload
# that never landed. Everything after that is Litestream's own behaviour.
#
# Expected: the follower reports that it cannot proceed (some error, or a
#   recovery), the way `restore` does.
# Observed: the follower logs nothing after "entering follow mode", never
#   advances, survives restart -- while `litestream restore` from the SAME
#   replica at the SAME moment restores the database completely.
set -uo pipefail
DB=/data/primary/db.sqlite
mkdir -p /data/primary /data/follower /data/control
log(){ printf '\n=== %s\n' "$*"; }
fail(){ printf '\nPRECONDITION FAILED: %s\n' "$*"; exit 2; }

# Defaults target the bundled MinIO; override to point at any S3-compatible store.
S3_ENDPOINT="${S3_ENDPOINT:-http://minio:9000}"
S3_BUCKET="${S3_BUCKET:-repro}"
S3_PREFIX="${S3_PREFIX:-db}"
mc alias set L "$S3_ENDPOINT" "${AWS_ACCESS_KEY_ID}" "${AWS_SECRET_ACCESS_KEY}" >/dev/null
# Start from an empty replica. Without this, a re-run writes a fresh database
# (txid 1..N) into a bucket that still holds the previous run's objects, and the
# leftovers look exactly like naturally-occurring holes.
mc rm --recursive --force "L/$S3_BUCKET/$S3_PREFIX" >/dev/null 2>&1 || true
# TXIDs are 16-hex in the object names; print them as decimal to avoid confusion.
lvl(){ mc ls --recursive "L/$S3_BUCKET" 2>/dev/null | awk '{print $NF}' | grep "^$S3_PREFIX/$1/" \
       | sed -E 's|.*/([0-9a-f]{16})-([0-9a-f]{16})\.ltx|\1 \2|' \
       | python3 -c 'import sys
for line in sys.stdin:
    a,b=line.split()
    print(f"{int(a,16)}-{int(b,16)}")'; }

litestream version
sqlite3 "$DB" "pragma journal_mode=WAL; create table t(id integer primary key, v text);" >/dev/null

litestream replicate > /tmp/primary.log 2>&1 &
sleep 3
for i in $(seq 1 12); do sqlite3 "$DB" "insert into t(v) values('a$i');" >/dev/null; sleep 0.6; done
sleep 3

log "replica contents"
echo "L0: $(lvl 0000 | tr '\n' ' ')"
echo "L1: $(lvl 0001 | tr '\n' ' ')"

# Assert L0 is contiguous BEFORE we remove anything, so the hole we demonstrate
# is unambiguously the one we made.
N0=$(lvl 0000 | wc -l)
(( N0 >= 8 )) || fail "expected >=8 L0 objects before removal, saw $N0 (listing broken or nothing replicated)"
GAPS=$(lvl 0000 | awk -F- 'NR>1 && $1 != prev+1 {print prev"->"$1} {prev=$2}')
[[ -n "$GAPS" ]] && fail "L0 already has gaps before removal: $GAPS"
echo "L0 is contiguous before removal: OK ($N0 objects)"

# Choose a victim that L1 has NOT already absorbed. If L1 already covers the TXID,
# removing its L0 is a no-op: fillFollowGap bridges via L1 and the follower correctly
# catches up. That is a useful positive control but it is not the scenario under test,
# and picking blindly makes the reproduction flaky.
MAXL1=$(lvl 0001 | awk -F- '{print $2}' | sort -n | tail -1); MAXL1=${MAXL1:-0}
echo "highest TXID already compacted into L1: $MAXL1"

VICTIM=""; VLO=0
while read -r key; do
  hex=$(sed -E 's|.*/([0-9a-f]{16})-[0-9a-f]{16}\.ltx|\1|' <<<"$key")
  lo=$((16#$hex))
  if (( lo > MAXL1 + 1 )); then VICTIM="$key"; VLO=$lo; break; fi
done < <(mc ls --recursive "L/$S3_BUCKET" | awk '{print $NF}' | grep "^$S3_PREFIX/0000/" | sort)

[[ -n "$VICTIM" ]] || fail "no L0 object above the L1 watermark ($MAXL1) available to remove"
PARK=$(( VLO - 1 ))
echo "victim TXID $VLO is above the L1 watermark; follower will park at $PARK"

if [[ "${NEGATIVE_CONTROL:-0}" == "1" ]]; then
  log "NEGATIVE CONTROL: removing nothing. The follower must catch up."
else
  log "removing ONE L0 object (simulating an upload that never landed): $VICTIM"
  mc rm "L/$S3_BUCKET/$VICTIM" >/dev/null
fi

for i in $(seq 13 24); do sqlite3 "$DB" "insert into t(v) values('b$i');" >/dev/null; sleep 0.6; done

log "waiting for L1 compaction to run (45s interval)"
sleep 60
echo "L1 is now: $(lvl 0001 | tr '\n' ' ')      <-- frozen; see the errors below"
grep -m2 'compaction failed' /tmp/primary.log

COVER=""
for L in 0000 0001 0002 0003; do
  c=$(lvl "$L" | awk -F- -v n="$VLO" -v lab="$L" '$1<=n && n<=$2 {print lab" "$1"-"$2}')
  [[ -n "$c" ]] && COVER="$COVER $c"
done
if [[ "${NEGATIVE_CONTROL:-0}" == "1" ]]; then
  echo "negative control: TXID $VLO coverage is:${COVER:- none}"
else
  [[ -z "$COVER" ]] || fail "TXID $VLO is still covered by:$COVER -- no hole exists, nothing to test"
  echo "no incremental coverage for TXID $VLO: confirmed"
fi

log "park a follower just below the removed txid, then follow"
# restore to an exact txid, then write the sidecar exactly as litestream does
# (fmt.Fprintln => 16 hex digits + newline), so `restore -f` resumes from there
# precisely as it would after a process restart.
PARKHEX=$(printf '%016x' "$PARK")
litestream restore -txid "$PARKHEX" -o /data/follower/db.sqlite "$DB" >/dev/null 2>&1
printf '%s\n' "$PARKHEX" > /data/follower/db.sqlite-txid
# LOG_LEVEL is read in the shared config path (cmd/litestream/main.go), so it
# applies to `restore` as well -- `restore` has no -log-level flag of its own.
LOG_LEVEL=trace litestream restore -f -follow-interval 1s -o /data/follower/db.sqlite "$DB" \
  > /tmp/follower.log 2>&1 &
FPID=$!
for s in 15 30 45 60; do
  sleep 15
  printf '  t+%-3s txid=%s rows=%s alive=%s\n' "${s}s" \
    "$(tr -d '\n' < /data/follower/db.sqlite-txid)" \
    "$(sqlite3 "file:/data/follower/db.sqlite?mode=ro" 'select count(*) from t;')" \
    "$(kill -0 $FPID 2>/dev/null && echo yes || echo no)"
done
kill $FPID 2>/dev/null; wait $FPID 2>/dev/null

log "follower log, in full (LOG_LEVEL=trace)"
cat /tmp/follower.log
echo "error/warning lines: $(grep -ciE 'error|warn' /tmp/follower.log)"

log "control: plain restore from the SAME replica"
litestream restore -o /data/control/db.sqlite "$DB" 2>&1 | tail -2
echo "rows restored: $(sqlite3 "file:/data/control/db.sqlite?mode=ro" 'select count(*) from t;')"
echo "rows in primary: $(sqlite3 "file:$DB?mode=ro" 'select count(*) from t;')"
echo "highest txid in replica: $( { lvl 0000; lvl 0001; lvl 0009; } | awk -F- '{print $2}' | sort -n | tail -1)"

Expected behavior: the follower reports that it cannot proceed, via an error or the "delete and re-restore" refusal it already emits for other unrecoverable states.

Actual behavior: it advances no further, logs nothing about it, and stays that way. 11 of 11 runs where the hole was asserted present; 2 of 2 controls with nothing deleted caught up fully.

Configuration

litestream.yml
yaml
levels:
  - interval: 45s
  - interval: 5m
  - interval: 1h
snapshot:
  interval: 20s        # must track the frontier; see note below
  retention: 24h
l0-retention: 300s
l0-retention-check-interval: 5s
dbs:
  - path: /data/primary/db.sqlite
    replicas:
      - type: s3
        bucket: repro
        path: db
        endpoint: http://minio:9000
        region: us-east-1
        force-path-style: true
        sync-interval: 1s

A snapshot must exist above the follower's position, otherwise the documented saved TXID ... is ahead of latest snapshot refusal fires instead and the failure is loud. Any long-running database satisfies that; a 3-minute test needs the short interval.

Logs

one run, unedited
=== park a follower just below the removed txid, then follow
  t+15s txid=0000000000000004 rows=4 alive=yes
  t+30s txid=0000000000000004 rows=4 alive=yes
  t+45s txid=0000000000000004 rows=4 alive=yes
  t+60s txid=0000000000000004 rows=4 alive=yes

=== follower log, in full
time=2026-08-31T04:08:46.335Z level=INFO msg="resuming follow mode from crash recovery" db=db.sqlite replica=s3 txid=0000000000000004 output=/data/follower/db.sqlite
time=2026-08-31T04:08:46.335Z level=INFO msg="entering follow mode" db=db.sqlite replica=s3 output=/data/follower/db.sqlite txid=0000000000000004 interval=1s
time=2026-08-31T04:09:46.331Z level=INFO msg="follow mode stopped" db=db.sqlite replica=s3
error/warning lines: 0

At LOG_LEVEL=trace the follower is not literally silent (it logs routine S3 traffic), but there is no ERROR or WARN in the entire stall window, and the output is indistinguishable from an idle follower with nothing to do. It issues a LIST against db/0000/ through db/0008/ on every poll: nine requests per interval, forever. At -follow-interval 1s that is roughly 780k LIST requests per day per stalled database, which may be of interest alongside #1390 and #1468.

Additional Context

Scope

What this reports: follow mode makes no progress and emits no error or warning when it cannot bridge a gap, and a restart resumes into the same state.

What it does not claim: that L0 objects go missing in practice. The reproduction removes one by hand, and so do the primary-side experiments below. They show what Litestream does given a missing object, not how often that happens. The one case I can show arising with nothing removed is the interaction with open PR #1514, below.

What it proposes: surfacing the condition (avenue 1), for which there is a PR. It takes no position on whether the gap-fill bound itself should change (avenue 2). That is a separate decision with consequences I cannot evaluate from outside.

Root cause

applyNewLTXFiles (replica.go:865) polls level 0 and calls fillFollowGap (replica.go:1007) when it sees a gap. That bridges levels 1..8:

go
for level := 1; level < SnapshotLevel; level++ {

SnapshotLevel is 9, so snapshots are never consulted. If no level can bridge, it returns the unchanged TXID and a nil error. Back in follow() (replica.go:804), a nil error with newTXID == lastTXID takes neither the error branch nor the newTXID > lastTXID branch, so nothing is logged on that iteration, or any subsequent one, since nothing in the loop changes.

Restart does not help. The resume guard at replica.go:653 is:

go
"cannot resume follow mode: saved TXID %s is behind the earliest snapshot (min TXID %s); replica history has been pruned -- delete %s and %s-txid to re-restore"

It names the right remedy, but it compares against latestSnapshot.MinTXID, and every snapshot object is 0009/0000000000000001-<max>. A trace-level LIST of that prefix during the reproduction returns

[0000000000000001-0000000000000001.ltx 0000000000000001-0000000000000002.ltx
 0000000000000001-0000000000000011.ltx 0000000000000001-0000000000000012.ltx]

so MinTXID is 1 and the check cannot fire for any saved TXID >= 1.

This is the shape AI_PR_GUIDE.md's own checklist warns about: "Does the code return errors to callers? Watch for log.Printf(err) followed by continue or return nil: this silently swallows failures." Here there is not even a log line.

How the gap can arise on the primary

The reproduction above removes an L0 object by hand. Separately, I looked at what Litestream itself does when an L0 object is missing, because that determines whether this state is reachable without anyone deleting anything.

Compact() seeks from W+1 (W = highest TXID in L1) and hands whatever LTXFiles returns to ltx.NewCompactor, which validates the inputs it is given. So the outcome depends on whether the surviving inputs are still contiguous from the seek point, not on where the missing file sits. Four conditions, three runs each, all twelve consistent:

deleted survivors compaction L1
W+1 W+2,W+3,… contiguous advances past the hole (1 transient failure) permanent gap W → W+2
W+1,W+2 W+3,… contiguous advances past the hole (1 transient failure) permanent gap W → W+3
W+2 W+1,W+3,… broken fails repeatedly, never recovers frozen at W
W+1,W+3 W+2,W+4,… broken fails repeatedly, never recovers frozen at W

Rows 2 and 4 both delete at the window start and behave oppositely, which is what rules out position as the cause.

The quiet outcome is the one that strands a follower. In the first case the gap is permanent (held across a four-minute window while L1 advanced from 15 to 33), and a follower parked at W stayed there for the whole window, alive and polling at 1s, with zero error or warning lines, while the missing TXID was covered only by a level-9 snapshot. In the loud case the follower actually advances one step, because the L0 file it needs is still there; compaction is wedged but the follow path is not blocked by an unbridgeable gap.

verify-compaction: true reports the quiet case by name (TXID gap detected: prev.MaxTXID=…, next.MinTXID=… (expected …)) and stays silent in the loud one, since it only runs after a successful compaction. It is off by default.

Two limits on this. The initiating L0 is still removed by hand, so this shows what happens given a lost upload, not that uploads are lost in practice. And it is a file:// replica in one configuration.

Interaction with open PR #1514

This is the part I would most like a second opinion on, because it changes how reachable the stall is. Everything above reaches the stall by removing an L0 object by hand. Open PR #1514 ("upload snapshots directly to L9, reduce compaction overhead") can reach the same stall with nothing deleted, though only under a particular ordering, which the third row of the table below pins down.

That PR routes snapshots straight to L9 and changes the compaction seek to seekTXID := max(prevMaxInfo.MaxTXID, snapInfo.MaxTXID) + 1, described in its own comment as

Snapshots are uploaded directly to the L9 SnapshotLevel, creating a "hole" in lower levels. Always seek past the newest snapshot.

It then relaxes VerifyLevelConsistency to accept a gap when a snapshot spans it, on the grounds that "restore seeds from it and extends contiguously." That holds for restore, which consults the snapshot level. It does not hold for follow, which does not: fillFollowGap stops at SnapshotLevel - 1.

I ran the same script against both, doing nothing but writing, snapshotting, compacting, and letting L0 retention age files out, with no object removed by hand at any point:

L1 after compaction L9 follower parked at TXID 3
main (4ed7a308) [1,12] [1,5] advances to 12
PR #1514, L1 empty when the snapshot-spanning compaction first ran [6,12] [1,1] [1,5] stays at 3, silently
PR #1514, L1 already covering [2,5] before the snapshot [2,5] [6,12] [1,1] [1,5] advances to 12

All three end with L0 pruned to [12,12] by retention (10-11 files), and in all three the follow call returned a nil error rather than refusing.

What the third row means, and how narrow this is. My first reading of this was too broad and