#26023·vector

docker_logs: a transient stream error replays every record since the Vector process started

Author: WilliamAGHCreated Aug 5, 2026Updated Sep 16, 2026

Problem

When a docker_logs log-follow stream ends with a transient error, the source does not resume from the last delivered record — it resumes from the Vector process start time and re-emits every line the container has logged since Vector booted.

The result is that a single recoverable stream blip re-delivers a container's entire history, and the amount re-delivered grows with collector uptime. In our environment a proxy in front of the Docker socket cut idle log-follow streams on a fixed 10-minute cadence; each cut produced a full replay. Measured re-delivery factors, grouping received records by exact timeUnixNano:

container window records distinct factor
low-volume cache service 95 min 128 13 9.8x
idle buildkit container ~3 days 71,151 264 269x

No records were lost and the receiver never applied backpressure — every duplicate was accepted. The 9.8x case is exactly one replay of all 13 records per 10-minute cut.

The code path

In src/sources/docker_logs/mod.rs (v0.56.0), a container's resume position lives in ContainerLogInfo, which is owned by the running stream task.

A clean stream end returns it, so the state survives:

rust
let result = match (result, error) {
    (Ok(()), None) => Ok(info),                                  // info preserved
    (Err(()), _)   => Err((info.id, ErrorPersistence::Permanent)),
    (_, Some(occurrence)) => Err((info.id, occurrence)),          // info DROPPED
};

The main loop resumes the clean case with restart, which reuses the existing info:

rust
Some(Ok(info)) => {
    let state = self.containers.get_mut(&info.id).expect(...);
    if state.return_info(info) { self.esb.restart(state); }
}

But the transient-error case only carries the ContainerId forward, so it goes through start:

rust
ErrorPersistence::Transient => if state.is_running() {
    let backoff = Some(self.backoff_duration);
    self.containers.insert(id.clone(), self.esb.start(id, backoff));
}

and start builds a brand new ContainerLogInfo seeded with the source's now_timestamp:

rust
let info = ContainerLogInfo::new(id, metadata, this.core.now_timestamp);

now_timestamp is captured once when the source is created, so it is the Vector process start time. last_log is now None, and log_since() therefore returns the process start rather than the last delivered record:

rust
fn log_since(&self) -> i64 {
    self.last_log.as_ref().map(|(d, _)| d.timestamp())
        .unwrap_or_else(|| self.created.timestamp()) - 1
}

That value is passed straight to the Docker API:

rust
.since(info.log_since() as i32)

The duplicate guard in new_event cannot help, because with last_log: None it takes the first-run branch, which admits everything after created:

rust
None => {
    if self.created < timestamp.with_timezone(&Utc) {
        // Noop - first log to process.
    }
}

So every record since the process started is re-emitted.

Reproduction

Minimal setup: a container that logs a handful of lines and then goes quiet, watched through a proxy that closes idle streams. Scaling the proxy's idle timeout to 30s and observing for 150s (five cut intervals), against timberio/vector@sha256:93b072b4... (0.56.0):

proxy behaviour records delivered distinct factor Error in communication with Docker daemon
cuts idle streams at 30s 65 13 5.0x 4
does not cut idle streams 13 13 1.0x 0

Five cuts, five replays of all 13 records. The count tracks the number of stream errors exactly.

Suggested fix

run_event_stream still owns info at the point it builds the error result, so the resume state is available and is simply discarded. Carrying it through the transient path — e.g. returning Err((info, ErrorPersistence::Transient)) and having the main loop re-arm the existing ContainerState rather than calling start — would make a transient error resume from last_log like the clean path already does.

A narrower fix would be to have start accept an optional previous ContainerLogInfo. Either way the goal is that a recoverable error costs at most the bounded one-record overlap the second-granularity since already implies, instead of the whole history since process start.

Related

Existing issues describe the watch churn itself but not this re-delivery consequence: #23847 (stops watching after a Docker daemon communication error), #20028 and #11056 (Started/Stopped watching loops). Checkpointing requests #7358, #20121 and #24869 would also address this, but the behaviour above looks like a straightforward bug in the existing resume logic rather than a missing feature: the state is already in hand and is thrown away.

Version

vector 0.56.0 (aarch64-unknown-linux-gnu 6817c02 2026-06-03)