Skip to main content

ant_core/node/daemon/forward/
es.rs

1//! The Elasticsearch bulk sink (V2-1016 contract).
2//!
3//! The endpoint is Elasticsearch itself behind a transparent reverse proxy, not a translation
4//! layer, so this speaks plain `_bulk`. Four details of that contract are easy to get wrong and
5//! expensive to debug, so they are stated here rather than left implicit in the code:
6//!
7//! 1. **The action must be `create`, never `index`.** The write key grants `create_doc`, which can
8//!    create but not overwrite — `index` comes back as a per-item 403. That restriction is
9//!    deliberate: it stops one beta participant overwriting another's document by `_id`.
10//! 2. **A `_bulk` response is `200 OK` even when documents failed.** Success is per position in
11//!    `items[]`; trusting the HTTP status alone silently discards failures.
12//! 3. **`200` at a position is a success, not a retry.** It means the server-side level filter
13//!    dropped the document. It is reported as success precisely so forwarders do not retry it
14//!    forever.
15//! 4. **`409` is also a success.** It means a document with that `_id` is already indexed — our own
16//!    earlier attempt landed after all. That is the entire point of the deterministic `_id`, and
17//!    treating it as an error would turn a successful recovery into a reported failure.
18//!
19//! The proxy forces `filter_path=errors,items.*.status,items.*.error` on the response, so positions
20//! line up with the submitted documents and a clean batch costs a handful of bytes to acknowledge.
21
22use std::time::Duration;
23
24use futures::future::BoxFuture;
25
26use super::document::ForwardDocument;
27use super::sink::{BatchOutcome, DocumentOutcome, LogSink};
28
29/// How long to wait for the endpoint before giving up on a batch.
30const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
31
32/// Ships documents to an Elasticsearch `_bulk` endpoint.
33pub struct ElasticsearchSink {
34    client: reqwest::Client,
35    bulk_url: String,
36    token: String,
37}
38
39impl ElasticsearchSink {
40    /// Build a sink for the given endpoint base and write-only API key.
41    pub fn new(endpoint_base: &str, token: &str) -> crate::error::Result<Self> {
42        let client = reqwest::Client::builder()
43            .timeout(REQUEST_TIMEOUT)
44            .build()
45            .map_err(|e| crate::error::Error::LogForward(format!("HTTP client: {e}")))?;
46
47        Ok(Self {
48            client,
49            bulk_url: format!("{}/_bulk", endpoint_base.trim_end_matches('/')),
50            token: token.to_string(),
51        })
52    }
53
54    /// Frame documents as an NDJSON bulk body.
55    ///
56    /// Each document contributes two lines — the `create` action naming its index and id, then its
57    /// source — and the body ends with a newline, which Elasticsearch requires.
58    #[must_use]
59    pub fn build_body(batch: &[ForwardDocument]) -> String {
60        let mut body = String::new();
61
62        for document in batch {
63            let action = serde_json::json!({
64                "create": { "_index": document.index, "_id": document.id }
65            });
66            // Serialization of these types cannot fail: the action is built from strings here, and
67            // the source is a plain struct of strings and options.
68            body.push_str(&serde_json::to_string(&action).unwrap_or_default());
69            body.push('\n');
70            body.push_str(&serde_json::to_string(&document.source).unwrap_or_default());
71            body.push('\n');
72        }
73
74        body
75    }
76
77    /// Map a per-item bulk status onto what the forwarder should do next.
78    #[must_use]
79    pub fn classify_item_status(status: u64) -> DocumentOutcome {
80        match status {
81            // Created, dropped by the server-side level filter, or already present from an earlier
82            // attempt of ours. All three mean "stop carrying this document around".
83            200..=299 | 409 => DocumentOutcome::Delivered,
84            // Busy or briefly unavailable.
85            429 | 500..=599 => DocumentOutcome::Retryable,
86            // Anything else — 400 mapping conflicts, 403 permission errors — will fail identically
87            // on every retry.
88            _ => DocumentOutcome::Rejected,
89        }
90    }
91
92    /// Interpret a bulk response body against the batch that produced it.
93    #[must_use]
94    pub fn classify_response(body: &str, batch_len: usize) -> BatchOutcome {
95        let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
96            // An unparseable body from a 2xx response is not something a retry will fix, but nor is
97            // it safe to call the documents delivered.
98            return BatchOutcome {
99                outcomes: vec![DocumentOutcome::Retryable; batch_len],
100                transport_failure: false,
101                error: Some("could not parse the bulk response".to_string()),
102            };
103        };
104
105        if value.get("errors").and_then(serde_json::Value::as_bool) == Some(false) {
106            return BatchOutcome::all_delivered(batch_len);
107        }
108
109        let Some(items) = value.get("items").and_then(serde_json::Value::as_array) else {
110            return BatchOutcome {
111                outcomes: vec![DocumentOutcome::Retryable; batch_len],
112                transport_failure: false,
113                error: Some("bulk response reported errors but listed no items".to_string()),
114            };
115        };
116
117        let mut outcomes = Vec::with_capacity(items.len());
118        // A batch can fail two ways at once — a transient 429 here, a permanent 403 there. Status
119        // output has room for one, and the permanent one is the one the user can act on, so it
120        // wins regardless of which came first in the array.
121        let mut permanent_error = None;
122        let mut transient_error = None;
123
124        for item in items {
125            // The action key is `create`, but read whatever key is present rather than assume it.
126            let entry = item
127                .as_object()
128                .and_then(|object| object.values().next())
129                .and_then(serde_json::Value::as_object);
130
131            let status = entry
132                .and_then(|entry| entry.get("status"))
133                .and_then(serde_json::Value::as_u64);
134
135            let outcome = match status {
136                Some(status) => {
137                    let classified = Self::classify_item_status(status);
138                    match classified {
139                        DocumentOutcome::Rejected if permanent_error.is_none() => {
140                            permanent_error = Some(describe_item_error(entry, status));
141                        }
142                        DocumentOutcome::Retryable if transient_error.is_none() => {
143                            transient_error = Some(describe_item_error(entry, status));
144                        }
145                        _ => {}
146                    }
147                    classified
148                }
149                // No status for this position: nothing is known, so do not claim it landed.
150                None => DocumentOutcome::Retryable,
151            };
152            outcomes.push(outcome);
153        }
154
155        // A response shorter than the batch leaves a tail unaccounted for; `deliver` retries any
156        // position it has no outcome for, so padding here would actively lose documents.
157        BatchOutcome {
158            outcomes,
159            transport_failure: false,
160            error: permanent_error.or(transient_error),
161        }
162    }
163}
164
165fn describe_item_error(
166    entry: Option<&serde_json::Map<String, serde_json::Value>>,
167    status: u64,
168) -> String {
169    let reason = entry
170        .and_then(|entry| entry.get("error"))
171        .and_then(|error| error.get("reason"))
172        .and_then(serde_json::Value::as_str);
173
174    match reason {
175        Some(reason) => format!("bulk item failed with {status}: {reason}"),
176        None => format!("bulk item failed with {status}"),
177    }
178}
179
180impl LogSink for ElasticsearchSink {
181    fn send<'a>(&'a self, batch: &'a [ForwardDocument]) -> BoxFuture<'a, BatchOutcome> {
182        Box::pin(async move {
183            if batch.is_empty() {
184                return BatchOutcome::all_delivered(0);
185            }
186
187            let response = self
188                .client
189                .post(&self.bulk_url)
190                .header("Authorization", format!("ApiKey {}", self.token))
191                .header("Content-Type", "application/x-ndjson")
192                .body(Self::build_body(batch))
193                .send()
194                .await;
195
196            let response = match response {
197                Ok(response) => response,
198                // The request never completed, so nothing is known about what landed. Replaying is
199                // safe because every document carries a deterministic `_id`.
200                Err(error) => return BatchOutcome::transport_failure(error.to_string()),
201            };
202
203            let status = response.status();
204
205            if status.is_success() {
206                let body = response.text().await.unwrap_or_default();
207                return Self::classify_response(&body, batch.len());
208            }
209
210            // A whole-request rejection. 429 and 5xx are worth another go; 401 (bad key), 413
211            // (body too large) and the rest will fail the same way every time.
212            let outcome = if status.as_u16() == 429 || status.is_server_error() {
213                DocumentOutcome::Retryable
214            } else {
215                DocumentOutcome::Rejected
216            };
217
218            BatchOutcome {
219                outcomes: vec![outcome; batch.len()],
220                transport_failure: false,
221                error: Some(format!("bulk request rejected with HTTP {status}")),
222            }
223        })
224    }
225
226    fn describe(&self) -> String {
227        self.bulk_url.clone()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::node::daemon::forward::document::DocumentSource;
235
236    fn document(id: &str, index: &str) -> ForwardDocument {
237        ForwardDocument {
238            id: id.to_string(),
239            index: index.to_string(),
240            source: DocumentSource {
241                timestamp: "2026-08-19T20:50:00.000000Z".to_string(),
242                level: "INFO".to_string(),
243                target: Some("ant_node::node".to_string()),
244                message: "hello".to_string(),
245                node_id: "7".to_string(),
246                service: "node7".to_string(),
247                binary_version: "0.17.2".to_string(),
248                channel: "beta".to_string(),
249                os: "linux".to_string(),
250                arch: "x86_64".to_string(),
251                peer_id: None,
252                version: None,
253                commit: None,
254            },
255        }
256    }
257
258    #[test]
259    fn the_bulk_action_is_create_not_index() {
260        let body = ElasticsearchSink::build_body(&[document("id-1", "beta-nodes-2026.08.19")]);
261        let action: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
262
263        assert!(
264            action.get("create").is_some(),
265            "`index` is refused with a per-item 403: {body}"
266        );
267        assert!(action.get("index").is_none());
268        assert_eq!(action["create"]["_index"], "beta-nodes-2026.08.19");
269        assert_eq!(action["create"]["_id"], "id-1");
270    }
271
272    #[test]
273    fn the_body_is_ndjson_with_the_required_trailing_newline() {
274        let body = ElasticsearchSink::build_body(&[
275            document("id-1", "beta-nodes-2026.08.19"),
276            document("id-2", "beta-nodes-2026.08.19"),
277        ]);
278
279        assert!(body.ends_with('\n'), "Elasticsearch requires it");
280        let lines: Vec<&str> = body.lines().collect();
281        assert_eq!(lines.len(), 4, "one action and one source per document");
282
283        for line in &lines {
284            serde_json::from_str::<serde_json::Value>(line)
285                .unwrap_or_else(|_| panic!("every line must be standalone JSON: {line}"));
286        }
287        let source: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
288        assert_eq!(source["@timestamp"], "2026-08-19T20:50:00.000000Z");
289    }
290
291    #[test]
292    fn an_empty_batch_produces_an_empty_body() {
293        assert_eq!(ElasticsearchSink::build_body(&[]), "");
294    }
295
296    /// 201 created, 200 dropped by the server-side level filter, and 409 already-indexed all mean
297    /// the forwarder is finished with the document.
298    #[test]
299    fn created_filtered_and_already_indexed_all_count_as_delivered() {
300        for status in [200, 201, 409] {
301            assert_eq!(
302                ElasticsearchSink::classify_item_status(status),
303                DocumentOutcome::Delivered,
304                "status {status}"
305            );
306        }
307    }
308
309    #[test]
310    fn busy_and_server_errors_are_retryable() {
311        for status in [429, 500, 502, 503] {
312            assert_eq!(
313                ElasticsearchSink::classify_item_status(status),
314                DocumentOutcome::Retryable,
315                "status {status}"
316            );
317        }
318    }
319
320    #[test]
321    fn permission_and_mapping_errors_are_permanent() {
322        for status in [400, 401, 403, 404] {
323            assert_eq!(
324                ElasticsearchSink::classify_item_status(status),
325                DocumentOutcome::Rejected,
326                "status {status}"
327            );
328        }
329    }
330
331    #[test]
332    fn a_clean_response_delivers_the_whole_batch() {
333        let outcome = ElasticsearchSink::classify_response(r#"{"errors":false}"#, 3);
334        assert_eq!(outcome.outcomes, vec![DocumentOutcome::Delivered; 3]);
335        assert!(outcome.error.is_none());
336        assert!(!outcome.transport_failure);
337    }
338
339    /// The shape the proxy's forced `filter_path` produces on a dirty batch: one entry per
340    /// submitted document, positions preserved.
341    #[test]
342    fn a_mixed_response_is_mapped_position_by_position() {
343        let body = r#"{
344            "errors": true,
345            "items": [
346                {"create": {"status": 201}},
347                {"create": {"status": 429}},
348                {"create": {"status": 403, "error": {"reason": "action [create] is unauthorized"}}},
349                {"create": {"status": 200}},
350                {"create": {"status": 409}}
351            ]
352        }"#;
353
354        let outcome = ElasticsearchSink::classify_response(body, 5);
355
356        assert_eq!(
357            outcome.outcomes,
358            vec![
359                DocumentOutcome::Delivered,
360                DocumentOutcome::Retryable,
361                DocumentOutcome::Rejected,
362                DocumentOutcome::Delivered,
363                DocumentOutcome::Delivered,
364            ]
365        );
366        let error = outcome.error.unwrap();
367        assert!(
368            error.contains("403") && error.contains("unauthorized"),
369            "the permanent failure is the actionable one, not the transient 429: {error}"
370        );
371    }
372
373    /// With nothing permanent to report, the transient failure is better than saying nothing.
374    #[test]
375    fn a_transient_error_is_surfaced_when_it_is_the_only_one() {
376        let body = r#"{"errors":true,"items":[{"create":{"status":429}}]}"#;
377        let error = ElasticsearchSink::classify_response(body, 1).error.unwrap();
378        assert!(error.contains("429"), "{error}");
379    }
380
381    /// A 409 is our own earlier attempt having landed — a recovery, not a failure worth reporting.
382    #[test]
383    fn a_conflict_is_not_reported_as_an_error() {
384        let body = r#"{"errors":true,"items":[{"create":{"status":409}}]}"#;
385        let outcome = ElasticsearchSink::classify_response(body, 1);
386
387        assert_eq!(outcome.outcomes, vec![DocumentOutcome::Delivered]);
388        assert!(
389            outcome.error.is_none(),
390            "a deduplicated replay is the mechanism working"
391        );
392    }
393
394    #[test]
395    fn a_short_items_array_leaves_the_tail_unaccounted_for() {
396        let body = r#"{"errors":true,"items":[{"create":{"status":201}}]}"#;
397        let outcome = ElasticsearchSink::classify_response(body, 3);
398
399        assert_eq!(
400            outcome.outcomes.len(),
401            1,
402            "the tail is left for deliver() to retry rather than assumed delivered"
403        );
404    }
405
406    #[test]
407    fn an_item_without_a_status_is_retried_rather_than_assumed_delivered() {
408        let body = r#"{"errors":true,"items":[{"create":{}}]}"#;
409        let outcome = ElasticsearchSink::classify_response(body, 1);
410        assert_eq!(outcome.outcomes, vec![DocumentOutcome::Retryable]);
411    }
412
413    #[test]
414    fn an_unparseable_body_is_retried_not_discarded() {
415        let outcome = ElasticsearchSink::classify_response("<html>gateway error</html>", 2);
416        assert_eq!(outcome.outcomes, vec![DocumentOutcome::Retryable; 2]);
417        assert!(outcome.error.unwrap().contains("parse"));
418    }
419
420    #[test]
421    fn errors_reported_without_items_are_retried() {
422        let outcome = ElasticsearchSink::classify_response(r#"{"errors":true}"#, 2);
423        assert_eq!(outcome.outcomes, vec![DocumentOutcome::Retryable; 2]);
424    }
425
426    #[test]
427    fn the_sink_describes_the_bulk_url_it_targets() {
428        let sink = ElasticsearchSink::new("https://logs.autonomi.com/", "key").unwrap();
429        assert_eq!(sink.describe(), "https://logs.autonomi.com/_bulk");
430    }
431}