Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 176 additions & 40 deletions crates/logfwd-output/src/json_lines.rs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

https://github.com/strawgate/memagent/blob/9ad2a6acfdfa68425bdac8cd6f3f8664a573570e/crates/logfwd-output/src/json_lines.rs#L103-L118

In send_batch_inner, self.stats.inc_lines(batch.num_rows() as u64) counts all rows regardless of whether they were actually written. In raw passthrough mode, rows where _raw is null are skipped during serialization (lines 106-112) but still counted in the stats, inflating the line count when _raw contains null values. Consider counting only rows that were actually written to the output buffer.

-            for row in 0..num_rows {
-                if !col.is_null(row) {
-                    self.batch_buf
-                        .extend_from_slice(str_value(col, row).as_bytes());
-                    self.batch_buf.push(b'\n');
-                }
-            }
+            let mut lines_written: u64 = 0;
+            for row in 0..num_rows {
+                if !col.is_null(row) {
+                    self.batch_buf
+                        .extend_from_slice(str_value(col, row).as_bytes());
+                    self.batch_buf.push(b'\n');
+                    lines_written += 1;
+                }
+            }
+            // Update stats with actual lines written, not total rows
+            self.stats.inc_lines(lines_written);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/logfwd-output/src/json_lines.rs around lines 103-118:

In `send_batch_inner`, `self.stats.inc_lines(batch.num_rows() as u64)` counts all rows regardless of whether they were actually written. In raw passthrough mode, rows where `_raw` is null are skipped during serialization (lines 106-112) but still counted in the stats, inflating the line count when `_raw` contains null values. Consider counting only rows that were actually written to the output buffer.

Evidence trail:
crates/logfwd-output/src/json_lines.rs line 217: `self.stats.inc_lines(batch.num_rows() as u64)` counts all rows. crates/logfwd-output/src/json_lines.rs lines 107-112: `if !col.is_null(row)` shows null `_raw` rows are skipped in raw passthrough mode. git_grep confirmed `inc_lines` is at line 217, `fn send_batch_inner` starts at line 128.

Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::future::Future;
use std::io;
use std::sync::Arc;

Expand All @@ -6,10 +7,9 @@ use arrow::record_batch::RecordBatch;

use logfwd_types::diagnostics::ComponentStats;

#[allow(deprecated)]
use super::{
BatchMetadata, Compression, HTTP_MAX_RETRIES, HTTP_RETRY_INITIAL_DELAY_MS, OutputSink,
build_col_infos, is_transient_error, str_value, write_row_json,
BatchMetadata, Compression, HTTP_MAX_RETRIES, HTTP_RETRY_INITIAL_DELAY_MS, build_col_infos,
str_value, write_row_json,
};

// ---------------------------------------------------------------------------
Expand All @@ -24,30 +24,31 @@ pub struct JsonLinesSink {
pub(crate) batch_buf: Vec<u8>,
compress_buf: Vec<u8>,
compression: Compression,
http_agent: ureq::Agent,
client: reqwest::Client,
stats: Arc<ComponentStats>,
}

impl JsonLinesSink {
/// Create a new JSON-lines sink.
///
/// The caller supplies a shared `reqwest::Client` (typically cloned from
/// the factory) so all workers reuse the same connection pool.
pub fn new(
name: String,
url: String,
headers: Vec<(String, String)>,
compression: Compression,
Comment on lines +34 to 40

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsonLinesSink::new now requires an explicit reqwest::Client, which is a breaking API change. There are existing call sites in this repo that still construct JsonLinesSink directly (e.g., bench + fuzz targets) and will fail to compile unless they are updated or a convenience constructor is kept.

Suggested change
/// The caller supplies a shared `reqwest::Client` (typically cloned from
/// the factory) so all workers reuse the same connection pool.
pub fn new(
name: String,
url: String,
headers: Vec<(String, String)>,
compression: Compression,
/// This convenience constructor preserves the historical API by creating
/// a default `reqwest::Client` for the sink.
pub fn new(
name: String,
url: String,
headers: Vec<(String, String)>,
compression: Compression,
stats: Arc<ComponentStats>,
) -> Self {
Self::with_client(
name,
url,
headers,
compression,
reqwest::Client::new(),
stats,
)
}
/// Create a new JSON-lines sink using a shared `reqwest::Client`.
///
/// Callers that manage sink creation centrally should prefer this so all
/// workers reuse the same connection pool.
pub fn with_client(
name: String,
url: String,
headers: Vec<(String, String)>,
compression: Compression,

Copilot uses AI. Check for mistakes.
client: reqwest::Client,
stats: Arc<ComponentStats>,
) -> Self {
let http_agent = ureq::config::Config::builder()
.timeout_global(Some(std::time::Duration::from_secs(30)))
.build()
.new_agent();
JsonLinesSink {
name,
url,
headers,
batch_buf: Vec::with_capacity(64 * 1024),
compress_buf: Vec::new(),
compression,
http_agent,
client,
stats,
}
}
Expand Down Expand Up @@ -118,21 +119,23 @@ impl JsonLinesSink {
}
Ok(())
}
}

#[allow(deprecated)]
impl OutputSink for JsonLinesSink {
fn send_batch(&mut self, batch: &RecordBatch, _metadata: &BatchMetadata) -> io::Result<()> {
/// Serialize, compress (if configured), and POST the batch over HTTP.
///
/// Retries transient failures (5xx, timeout, and connection errors) with
/// exponential backoff. Returns `SendResult::RetryAfter` on HTTP 429 and
/// `SendResult::Rejected` on non-429 4xx client errors.
async fn send_batch_inner(
&mut self,
batch: &RecordBatch,
) -> io::Result<super::sink::SendResult> {
Comment on lines +123 to +131

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces substantial new HTTP behavior (reqwest transport, retry/backoff, 429 → RetryAfter, 4xx → Rejected). The current unit tests in this module cover serialization but not the send/retry/status-handling logic; adding async tests (e.g., with a lightweight local HTTP server) would help prevent regressions around status classification and Retry-After parsing.

Copilot uses AI. Check for mistakes.
self.serialize_batch(batch)?;
if self.batch_buf.is_empty() {
return Ok(());
return Ok(super::sink::SendResult::Ok);
}

// Retry with exponential backoff for transient failures.
// 1 initial attempt + up to HTTP_MAX_RETRIES retries; delays: 100ms → 200ms → 400ms.
// Note: `self.batch_buf` is re-sent as `&[u8]` on each attempt — no
// allocation, but the full NDJSON payload is retransmitted each time.
// This is acceptable as a temporary measure until SinkDriver (#319).
// Compress if configured. The compressed payload is stored in
// `self.compress_buf` and lives until the next call.
let payload: &[u8] = match self.compression {
Compression::Gzip => {
use flate2::Compression as GzLevel;
Expand All @@ -152,38 +155,169 @@ impl OutputSink for JsonLinesSink {
unreachable!("zstd is not supported for json_lines; rejected at config validation")
}
};
let build_req = || {
let mut req = self.http_agent.post(&self.url);
for (k, v) in &self.headers {
req = req.header(k.as_str(), v.as_str());
}
req = req.header("Content-Type", "application/x-ndjson");
if self.compression == Compression::Gzip {
req = req.header("Content-Encoding", "gzip");
}
req
};

// Retry with exponential backoff for transient failures.
// 1 initial attempt + up to HTTP_MAX_RETRIES retries; delays: 100ms → 200ms → 400ms.
let mut delay_ms: u64 = HTTP_RETRY_INITIAL_DELAY_MS;
let mut attempt: u32 = 0;

// Own the payload once; on retries we call `.clone()` which allocates
// but is acceptable for a path limited to HTTP_MAX_RETRIES iterations.
let body = payload.to_vec();

loop {
Comment on lines +164 to 168

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let body = payload.to_vec(); plus body.clone() on every attempt copies the full (potentially large) payload per retry and adds an extra allocation even for the first attempt. To avoid this regression vs the old slice-based resend, consider using a cheap-clone buffer type (e.g., bytes::Bytes) or otherwise sharing the underlying payload across retries without re-copying.

Copilot uses AI. Check for mistakes.
match build_req().send(payload) {
Ok(_) => {
self.stats.inc_lines(batch.num_rows() as u64);
self.stats.inc_bytes(payload.len() as u64);
return Ok(());
let mut req = self
.client
.post(&self.url)
.header("Content-Type", "application/x-ndjson");
if self.compression == Compression::Gzip {
req = req.header("Content-Encoding", "gzip");
}
for (k, v) in &self.headers {
req = req.header(k.as_str(), v.as_str());
}
let req = req.body(body.clone());

match req.send().await {
Ok(response) => {
let status = response.status();

if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
// Parse Retry-After header (seconds integer) or use default.
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(5);
return Ok(super::sink::SendResult::RetryAfter(
std::time::Duration::from_secs(retry_after),
));
}

if status.is_client_error() {
return Ok(super::sink::SendResult::Rejected(format!(
"HTTP endpoint rejected batch with HTTP {status}"
)));
}
Comment on lines +198 to +202

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send_batch_inner treats any status that is neither 4xx nor 5xx as success. That means 3xx redirects (and other non-success codes) will incorrectly be counted as delivered. Consider explicitly checking status.is_success() and handling non-success/non-4xx/non-5xx statuses as an error or rejection.

Copilot uses AI. Check for mistakes.

if status.is_server_error() {
if attempt < HTTP_MAX_RETRIES {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
delay_ms *= 2;
attempt += 1;
continue;
}
return Err(io::Error::other(format!(
"HTTP endpoint returned {status} after {attempt} retries"
)));
}

if status.is_success() {
self.stats.inc_lines(batch.num_rows() as u64);
self.stats.inc_bytes(payload.len() as u64);
return Ok(super::sink::SendResult::Ok);
}

return Err(io::Error::other(format!(
"HTTP endpoint returned non-success status {status}"
)));
}
Err(e) if attempt < HTTP_MAX_RETRIES && is_transient_error(&e) => {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
Err(e) if attempt < HTTP_MAX_RETRIES && (e.is_timeout() || e.is_connect()) => {
// Retry transient transport errors only.
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
delay_ms *= 2;
attempt += 1;
}
Err(e) => return Err(io::Error::other(e.to_string())),
Err(e) => {
return Err(io::Error::other(format!(
"HTTP request failed after {attempt} retries: {e}"
)));
}
}
}
}
}

fn flush(&mut self) -> io::Result<()> {
Ok(())
impl super::sink::Sink for JsonLinesSink {
fn send_batch<'a>(
&'a mut self,
batch: &'a RecordBatch,
_metadata: &'a BatchMetadata,
) -> std::pin::Pin<Box<dyn Future<Output = io::Result<super::sink::SendResult>> + Send + 'a>>
{
Box::pin(async move { self.send_batch_inner(batch).await })
}

fn flush(&mut self) -> std::pin::Pin<Box<dyn Future<Output = io::Result<()>> + Send + '_>> {
Box::pin(async { Ok(()) })
}

fn name(&self) -> &str {
&self.name
}

fn shutdown(&mut self) -> std::pin::Pin<Box<dyn Future<Output = io::Result<()>> + Send + '_>> {
Box::pin(async { Ok(()) })
}
}

// ---------------------------------------------------------------------------
// JsonLinesSinkFactory
// ---------------------------------------------------------------------------

/// Creates [`JsonLinesSink`] instances for the output worker pool.
///
/// All workers share a single `reqwest::Client` (which is internally
/// `Arc`-wrapped) so they reuse the same connection pool, TLS sessions,
/// and DNS cache.
pub struct JsonLinesSinkFactory {
name: String,
url: String,
headers: Vec<(String, String)>,
compression: Compression,
client: reqwest::Client,
stats: Arc<ComponentStats>,
}

impl JsonLinesSinkFactory {
/// Create a new factory.
///
/// Builds a shared `reqwest::Client` with a 30-second timeout and a
/// generous idle-connection pool.
pub fn new(
name: String,
url: String,
headers: Vec<(String, String)>,
compression: Compression,
stats: Arc<ComponentStats>,
) -> io::Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.pool_max_idle_per_host(64)
.build()
.map_err(io::Error::other)?;
Ok(JsonLinesSinkFactory {
name,
url,
headers,
compression,
client,
stats,
})
}
}

impl super::sink::SinkFactory for JsonLinesSinkFactory {
fn create(&self) -> io::Result<Box<dyn super::sink::Sink>> {
Ok(Box::new(JsonLinesSink::new(
self.name.clone(),
self.url.clone(),
self.headers.clone(),
self.compression,
self.client.clone(), // reqwest::Client is Arc-wrapped, clone is cheap
Arc::clone(&self.stats),
)))
}

fn name(&self) -> &str {
Expand All @@ -203,11 +337,13 @@ mod tests {
use logfwd_types::diagnostics::ComponentStats;

fn make_sink() -> JsonLinesSink {
let client = reqwest::Client::new();
JsonLinesSink::new(
"test".to_string(),
"http://localhost:1".to_string(), // unreachable — tests only call serialize_batch
vec![],
Compression::None,
client,
Arc::new(ComponentStats::default()),
)
}
Expand Down
Loading
Loading