use crate::{platform::event_bus::EventBus, store::Db};
use bollard::{
Docker,
container::{LogOutput, LogsOptions},
};
use chrono::{DateTime, Utc};
use futures::StreamExt;
use shared::{Event, protocol::LogStream};
use std::{collections::HashSet, sync::Arc, time::Duration};
use tokio::{sync::Mutex, time::interval};
use tracing::{debug, warn};
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
pub async fn stream_loop(docker: Arc<Docker>, db: Arc<Db>, bus: Arc<EventBus>) {
let active: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
let mut ticker = interval(Duration::from_secs(5));
loop {
ticker.tick().await;
let services = match crate::store::services::get_running(&db).await {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "logs: failed to get running services");
continue;
}
};
let new_containers: Vec<(String, String)> = {
let locked = active.lock().await;
services
.into_iter()
.filter_map(|svc| {
let cid = svc.live_container_id?;
if locked.contains(&cid) {
return None;
}
Some((cid, svc.id))
})
.collect()
};
for (container_id, service_id) in new_containers {
active.lock().await.insert(container_id.clone());
let docker2 = docker.clone();
let bus2 = bus.clone();
let active2 = active.clone();
let cid = container_id.clone();
tokio::spawn(async move {
stream_container(docker2, cid.clone(), service_id, bus2).await;
active2.lock().await.remove(&cid);
});
}
}
}
async fn stream_container(
docker: Arc<Docker>,
container_id: String,
service_id: String,
bus: Arc<EventBus>,
) {
let resume_from = last_persisted(&service_id, &container_id).await;
let opts = LogsOptions::<String> {
follow: true,
stdout: true,
stderr: true,
timestamps: true,
since: resume_from.map_or(0, |t| t.timestamp()),
tail: if resume_from.is_some() {
"all".into()
} else {
"100".into()
},
..Default::default()
};
let mut stream = docker.logs(&container_id, Some(opts));
let mut stamper = Stamper::default();
loop {
match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Ok(Some(Ok(LogOutput::StdOut { message }))) => {
publish_log(
&bus,
&mut stamper,
&service_id,
&container_id,
false,
&message,
);
}
Ok(Some(Ok(LogOutput::StdErr { message }))) => {
publish_log(
&bus,
&mut stamper,
&service_id,
&container_id,
true,
&message,
);
}
Ok(Some(Ok(_))) => continue,
Ok(Some(Err(e))) => {
debug!(error = %e, container = %container_id, "logs: stream encerrado com erro");
break;
}
Ok(None) => {
debug!(container = %container_id, "logs: stream encerrado");
break;
}
Err(_) => {
let running = docker
.inspect_container(
&container_id,
None::<bollard::container::InspectContainerOptions>,
)
.await
.ok()
.and_then(|info| info.state?.running)
.unwrap_or(false);
if !running {
debug!(container = %container_id, "logs: container parou, encerrando stream travado");
break;
}
}
}
}
}
async fn last_persisted(service_id: &str, container_id: &str) -> Option<DateTime<Utc>> {
let store = crate::platform::log_store::global()?;
let (sid, cid) = (service_id.to_string(), container_id.to_string());
tokio::task::spawn_blocking(move || store.last_timestamp(&sid, &cid))
.await
.ok()
.flatten()
}
#[derive(Default)]
struct Stamper {
prev: Option<DateTime<Utc>>,
repeats: i64,
}
impl Stamper {
fn stamp(&mut self, real: DateTime<Utc>) -> DateTime<Utc> {
if self.prev == Some(real) {
self.repeats += 1;
} else {
self.prev = Some(real);
self.repeats = 0;
}
real + chrono::Duration::nanoseconds(self.repeats)
}
}
fn split_timestamp(raw: &str) -> (DateTime<Utc>, &str) {
if let Some((head, rest)) = raw.split_once(' ')
&& let Ok(at) = DateTime::parse_from_rfc3339(head)
{
return (at.with_timezone(&Utc), rest);
}
(Utc::now(), raw)
}
fn publish_log(
bus: &EventBus,
stamper: &mut Stamper,
service_id: &str,
container_id: &str,
is_stderr: bool,
bytes: &[u8],
) {
let raw = String::from_utf8_lossy(bytes);
for line in raw.split('\n') {
let (at, text) = split_timestamp(line.trim_end_matches('\r'));
if text.is_empty() {
continue;
}
let at = stamper.stamp(at);
if let Some(store) = crate::platform::log_store::global() {
store.append(service_id, container_id, is_stderr, at, text);
}
bus.publish(Event::LogLine {
service_id: service_id.to_string(),
container_id: container_id.to_string(),
stream: if is_stderr {
LogStream::Stderr
} else {
LogStream::Stdout
},
line: text.to_string(),
timestamp: at,
});
}
}
pub async fn capture_final(docker: &Docker, service_id: &str, container_id: &str) {
let Some(store) = crate::platform::log_store::global() else {
return;
};
let since = last_persisted(service_id, container_id).await;
let opts = LogsOptions::<String> {
follow: false,
stdout: true,
stderr: true,
timestamps: true,
since: since.map_or(0, |t| t.timestamp()),
tail: "all".into(),
..Default::default()
};
let mut stream = docker.logs(container_id, Some(opts));
let mut stamper = Stamper::default();
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let next = tokio::time::timeout_at(deadline, stream.next()).await;
let (is_err, message) = match next {
Ok(Some(Ok(LogOutput::StdOut { message }))) => (false, message),
Ok(Some(Ok(LogOutput::StdErr { message }))) => (true, message),
Ok(Some(Ok(_))) => continue,
_ => break,
};
for line in String::from_utf8_lossy(&message).split('\n') {
let (at, text) = split_timestamp(line.trim_end_matches('\r'));
if !text.is_empty() {
let at = stamper.stamp(at);
store.append(service_id, container_id, is_err, at, text);
}
}
}
let store2 = store.clone();
let _ = tokio::task::spawn_blocking(move || store2.flush()).await;
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn stamper_numera_linhas_que_dividem_o_mesmo_segundo() {
let t1 = Utc.timestamp_opt(1_800_000_000, 0).unwrap();
let t2 = Utc.timestamp_opt(1_800_000_001, 0).unwrap();
let mut s = Stamper::default();
let got: Vec<_> = [t1, t1, t1, t2, t2, t1]
.into_iter()
.map(|t| s.stamp(t).timestamp_subsec_nanos())
.collect();
assert_eq!(got, [0, 1, 2, 0, 1, 0]);
let mut again = Stamper::default();
assert_eq!(again.stamp(t1).timestamp_subsec_nanos(), 0);
assert_eq!(again.stamp(t1).timestamp_subsec_nanos(), 1);
}
#[test]
fn separa_o_prefixo_de_instante_do_docker() {
let (at, texto) = split_timestamp("2026-11-05T03:43:14.913865516Z linha com espaço");
assert_eq!(texto, "linha com espaço");
assert_eq!(at.timestamp_subsec_nanos(), 913_865_516);
let (_, sem_prefixo) = split_timestamp("sem timestamp aqui");
assert_eq!(sem_prefixo, "sem timestamp aqui");
}
}