Skip to main content

rustrade_data/exchange/lse/
export.rs

1//! Bulk export jobs against the vault: submit, poll, download.
2//!
3//! The vault serves candles over REST ([`historical`](super::historical)), but the **raw tick tape
4//! is reachable only here** — there is no streaming or REST path to it. Exports are asynchronous:
5//! submit a job, poll it to `ready`, then download the artifact.
6//!
7//! # ⚠️ This is a different API surface from the candles endpoint
8//! Measured differences that a client written against the candles path gets wrong:
9//!
10//! - Submit answers **`202 Accepted`**, not `200`. A `== 200` check never polls.
11//! - The job-id field is **renamed between responses**: the submit body says `job_id`, the status
12//!   body says `id`. Hence [`LseExportJob`] and [`LseExportJobStatus`] are separate types.
13//! - Allowance exhaustion is a **`429` with no `Retry-After`**, mapped here to
14//!   [`LseError::QuotaExceeded`] rather than [`LseError::RateLimited`] — an exhausted export
15//!   allowance is a different condition from a per-minute rate limit, and only the former carries a
16//!   [`QuotaStatus`](super::quota::QuotaStatus).
17//!
18//! # ⚠️ A rejected submit still costs you an export
19//! The allowance is **5 exports per hour**, and the counter increments on *attempt*: a `400` for a
20//! misspelled dataset consumes 20% of the hour's budget just as a successful job does. (Requests
21//! rejected at the CDN edge do not count.) This is why [`LseExportRequest::new`] validates
22//! everything it can before a request is ever sent — pre-flight validation here is an **economic**
23//! requirement, not defensive style.
24//!
25//! # ⚠️ `symbol: "all"` does not mean "every symbol", and every artifact is single-symbol
26//! It is a literal that matches nothing. Measured as a controlled pair on one dataset, timeframe
27//! and range: `symbol: "all"` returned `202` → `ready` → a **valid Parquet file with the full
28//! schema and zero rows**, while the same request naming a real symbol returned rows. There is no
29//! error and no warning, and it costs an export. The tick path behaves identically, and omitting
30//! `symbol` is a hard `400` — so **no spelling produces a multi-symbol export**, and an export
31//! naming `"all"` is rejected before it is sent; see [`LseExportRequest::new`].
32//!
33//! The consequence reaches the consumer: combining N instruments means N artifacts merged with
34//! [`merge_time_sorted`](crate::streams::merge::merge_time_sorted), as on the candle replay path.
35//! A decoded artifact is a synchronous iterator of already-tagged events, so it needs adapting to
36//! that merge's stream item first; `read_export` documents the adapter. (Not linked: the decoder is
37//! behind its own feature, so the link would be dead in a build without it.)
38//!
39//! The rejection is **case-sensitive on purpose**: `ALL` is Allstate's real ticker, so refusing it
40//! would forbid a correct export with no way around it. "Matches nothing" is a property of the
41//! (dataset, symbol) pair, not of the string.
42//!
43//! # Range semantics
44//! `start` is **inclusive** and `end` is **exclusive**, matching the candles endpoint. Measured
45//! twice: a tick export over `[2026-07-01, 2026-07-02)` yielded only 2026-07-01, and a daily candle
46//! export ending `2026-07-20` produced no bar for the 20th. The range is **date-granular**, which
47//! [`LseExportRange`] makes explicit by taking [`NaiveDate`] rather than silently truncating a
48//! timestamp.
49//!
50//! # ⚠️ Licensing
51//! Exported data is **not redistributable**. An export produces a file on your disk that is
52//! trivially easy to commit or share by accident; it must not be. See the
53//! [module documentation](super) and <https://londonstrategicedge.com/terms>.
54
55use crate::exchange::http::{MAX_ERROR_BODY_DOWNLOAD_BYTES, read_body_capped};
56use crate::exchange::lse::error::{LseError, extract_detail};
57use crate::exchange::lse::market::{LseDataset, candle_interval_str};
58use crate::exchange::lse::vault::LseVaultClient;
59use crate::subscription::candle::CandleInterval;
60use chrono::{DateTime, NaiveDate, Utc};
61use futures_util::StreamExt;
62use rustrade_instrument::exchange::ExchangeId;
63use serde::{Deserialize, Serialize};
64use sha2::{Digest, Sha256};
65use std::path::{Path, PathBuf};
66use std::time::Duration;
67use tokio::io::{AsyncReadExt, AsyncWriteExt};
68use tracing::{debug, info, warn};
69
70/// Size of the buffer used to read an existing `.part` file back into the hasher.
71const HASH_READ_BUFFER_BYTES: usize = 64 * 1024;
72
73/// Size of the write buffer coalescing download chunks before they reach the `.part` file.
74const DOWNLOAD_BUFFER_BYTES: usize = 256 * 1024;
75
76/// Total deadline for one artifact transfer, overriding the client's JSON-shaped one.
77///
78/// The shared client sets a 30 s *total* deadline, which `reqwest` applies through to the end of the
79/// response body — correct for a page of JSON, fatal for a multi-gigabyte artifact, which would abort
80/// at 30 s however healthy the connection. Six hours covers a 7 GB artifact on a link as slow as
81/// ~3 Mbit/s. It is a backstop, not the failure detector: a stall is caught in seconds by the
82/// client's per-read timeout, and an interrupted transfer resumes from the `.part` file.
83const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(6 * 60 * 60);
84
85/// The literal the provider treats as a symbol rather than as "every symbol".
86const ALL_SYMBOLS_LITERAL: &str = "all";
87
88/// The only export format this integration decodes.
89const EXPORT_FORMAT: &str = "parquet";
90
91/// What an export job covers, in the provider's own timeframe vocabulary.
92///
93/// [`Tick`](Self::Tick) is the whole point of the export path: the raw tape is not reachable over
94/// REST or WebSocket.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum LseExportTimeframe {
97    /// The raw tick tape.
98    ///
99    /// # ⚠️ The tick schema varies by dataset
100    /// Measured: `fx` exports `{ts, symbol, bid, ask}` (a quote tape), `stocks` exports
101    /// `{ts, symbol, price, volume}` (a trade tape). A decoder must dispatch on the columns
102    /// actually present rather than assume one shape.
103    Tick,
104
105    /// Aggregated candles at the given resolution.
106    Candle(CandleInterval),
107}
108
109impl LseExportTimeframe {
110    /// Returns the provider's spelling of this timeframe.
111    ///
112    /// # Errors
113    /// Returns [`LseError::UnsupportedInterval`] for a resolution the provider does not serve.
114    pub fn as_lse_str(&self) -> Result<&'static str, LseError> {
115        match self {
116            Self::Tick => Ok("tick"),
117            Self::Candle(interval) => {
118                candle_interval_str(*interval).ok_or(LseError::UnsupportedInterval {
119                    interval: *interval,
120                })
121            }
122        }
123    }
124}
125
126/// The date range an export covers.
127///
128/// `start` is inclusive, `end` is **exclusive**, and both are dates rather than timestamps —
129/// the export endpoint is date-granular, and taking [`NaiveDate`] states that in the type instead
130/// of silently discarding a caller's time-of-day.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
132pub struct LseExportRange {
133    start: NaiveDate,
134    end: NaiveDate,
135}
136
137impl LseExportRange {
138    /// Construct a range.
139    ///
140    /// # Errors
141    /// Returns [`LseError::InvalidInput`] unless `start < end`. An empty or inverted range would
142    /// produce a valid, empty artifact at the cost of one export.
143    pub fn new(start: NaiveDate, end: NaiveDate) -> Result<Self, LseError> {
144        if start >= end {
145            return Err(LseError::InvalidInput {
146                message: format!(
147                    "export range start {start} must be before end {end} (end is exclusive)"
148                ),
149            });
150        }
151
152        Ok(Self { start, end })
153    }
154
155    /// The inclusive first date.
156    pub fn start(&self) -> NaiveDate {
157        self.start
158    }
159
160    /// The exclusive last date.
161    pub fn end(&self) -> NaiveDate {
162        self.end
163    }
164
165    /// Whether this range starts more than one day after `today`, and so is guaranteed to hold no
166    /// data.
167    ///
168    /// Separate from [`new`](Self::new), and pure, deliberately: "is this range in the future"
169    /// depends on when the question is asked, so a constructor answering it would bake one reading
170    /// of the clock into a value that outlives it — and would make the type untestable without one.
171    /// [`submit_export`](super::vault::LseVaultClient::submit_export) asks it at the point the
172    /// export is actually spent, which is the instant the answer matters.
173    ///
174    /// # Why a whole day of slack
175    /// `today` is read in UTC while the caller may be thinking in local time, and the widest real
176    /// offset is UTC+14 — so a caller's "today" can legitimately be UTC's tomorrow, and never more.
177    /// Comparing against `today + 1` therefore cannot reject a range someone meant, while still
178    /// catching the failure this exists for: a fat-fingered year, which misses by hundreds of days.
179    #[must_use]
180    pub fn is_wholly_after(&self, today: NaiveDate) -> bool {
181        self.start
182            .checked_sub_days(chrono::Days::new(1))
183            .is_some_and(|slack| slack > today)
184    }
185}
186
187/// A validated export request.
188///
189/// Construction is the validation step; see [`new`](Self::new). Holding one of these means the
190/// request is worth spending an export on, as far as anything checkable client-side goes.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct LseExportRequest {
193    dataset: LseDataset,
194    symbol: String,
195    timeframe: LseExportTimeframe,
196    range: LseExportRange,
197}
198
199impl LseExportRequest {
200    /// Validate and build an export request.
201    ///
202    /// # Why this validates so much
203    /// A rejected submit consumes one of five hourly exports. Every check here is a rejection the
204    /// provider would otherwise bill for.
205    ///
206    /// # Errors
207    /// - [`LseError::InvalidInput`] if `symbol` is blank. Surrounding whitespace is trimmed before
208    ///   any check, so a padded symbol is neither smuggled past the `"all"` rejection below nor
209    ///   sent verbatim into a `400` that would cost an export.
210    /// - [`LseError::InvalidInput`] if `symbol` is `"all"`, on **either** timeframe — measured on
211    ///   both to produce a valid, **empty** artifact with no error (see the
212    ///   [module documentation](self)). The exact spelling `"ALL"` is admitted on
213    ///   [`Stocks`](LseDataset::Stocks) and [`Etf`](LseDataset::Etf) only, where it is Allstate's
214    ///   real ticker; on every other dataset a symbol is a pair or a contract slug, so no casing of
215    ///   it names anything.
216    /// - [`LseError::InvalidInput`] if a candle resolution is requested for one of the provider's
217    ///   synthetic classes. Those serve candles over REST but are **tick-only on the export path**
218    ///   — measured: `{dataset: volatility, timeframe: 1d}` → `400 … it is tick-only`.
219    /// - [`LseError::UnsupportedInterval`] for a resolution the provider does not serve.
220    pub fn new(
221        dataset: LseDataset,
222        symbol: impl Into<String>,
223        timeframe: LseExportTimeframe,
224        range: LseExportRange,
225    ) -> Result<Self, LseError> {
226        // Trimmed before anything reads it, including the `"all"` comparison below and the request
227        // body. Incidental whitespace is a copy-paste artefact, and leaving it in would both slip
228        // `" all"` past an exact-match guard and buy a billed 400 for a symbol that is otherwise
229        // correct.
230        let symbol: String = symbol.into();
231        let symbol = symbol.trim().to_owned();
232
233        if symbol.is_empty() {
234            return Err(LseError::InvalidInput {
235                message: "export symbol must not be blank; the provider rejects a missing symbol \
236                          with a 400, which still consumes an export"
237                    .to_owned(),
238            });
239        }
240
241        // Rejecting the resolution before the request is sent, not after being billed for a 400.
242        let _ = timeframe.as_lse_str()?;
243
244        // Case-insensitive with one narrow escape hatch: `ALL` is Allstate's real ticker (verified
245        // live against the provider's own price endpoint), so rejecting *that* spelling on an
246        // equity dataset would forbid a correct export with no way around it. Every other casing --
247        // `all`, `All`, `aLL` -- is nobody's real symbol, because the provider publishes symbols
248        // uppercase, and title case is exactly what a spreadsheet produces. Each one costs a billed
249        // export to discover it matched nothing, so the guard covers them rather than only the
250        // lowercase form.
251        //
252        // The escape hatch is scoped to the datasets where a bare equity ticker is even
253        // representable. "Matches nothing" is a property of the (dataset, symbol) PAIR, and
254        // `dataset` is right here: on `Fx` a symbol is pair-shaped (`EUR/USD`), on the index,
255        // commodity, rates, volatility and futures classes it is a contract slug, and on `Crypto`
256        // it is a pair -- so `ALL` is not Allstate on any of them, it is the same zero-row artifact
257        // as `all`, and letting it through spends a billed export to learn that. `Stocks` and `Etf`
258        // share one venue and one symbology (see `LseDataset::exchange_id`), so the hatch covers
259        // both rather than assuming Allstate is the only uppercase-`ALL` listing.
260        let equity_symbology = matches!(dataset, LseDataset::Stocks | LseDataset::Etf);
261        if !(equity_symbology && symbol == "ALL")
262            && symbol.eq_ignore_ascii_case(ALL_SYMBOLS_LITERAL)
263        {
264            // Only the equity classes get told about Allstate; naming it on `fx` would send the
265            // reader after a listing that dataset does not carry.
266            let hint = if equity_symbology {
267                format!(
268                    " (If you meant Allstate, its symbol is exactly {:?}, uppercase.)",
269                    ALL_SYMBOLS_LITERAL.to_uppercase()
270                )
271            } else {
272                format!(
273                    " No casing of it is a symbol on dataset {:?}, whose symbols are not bare \
274                     equity tickers.",
275                    dataset.as_catalog_str()
276                )
277            };
278
279            return Err(LseError::InvalidInput {
280                message: format!(
281                    "symbol {symbol:?} is a literal the provider matches against the symbol \
282                     column, not a request for every symbol: an export naming it returns a valid \
283                     but EMPTY artifact, with no error, and still consumes one of five hourly \
284                     exports - name a real symbol instead. Measured on both the candle and the \
285                     tick path; every artifact this provider will produce is single-symbol.{hint}"
286                ),
287            });
288        }
289
290        if let LseExportTimeframe::Candle(interval) = timeframe
291            && !dataset.is_candle_class()
292        {
293            return Err(LseError::InvalidInput {
294                message: format!(
295                    "dataset {:?} is tick-only on the export path, so resolution {interval} \
296                     cannot be exported for it - it serves candles over REST, but export \
297                     timeframes follow the provider's candle-class split; use \
298                     LseExportTimeframe::Tick",
299                    dataset.as_catalog_str()
300                ),
301            });
302        }
303
304        Ok(Self {
305            dataset,
306            symbol,
307            timeframe,
308            range,
309        })
310    }
311
312    /// The dataset this request targets.
313    pub fn dataset(&self) -> LseDataset {
314        self.dataset
315    }
316
317    /// The display symbol this request targets.
318    pub fn symbol(&self) -> &str {
319        &self.symbol
320    }
321
322    /// The requested timeframe.
323    pub fn timeframe(&self) -> LseExportTimeframe {
324        self.timeframe
325    }
326
327    /// The requested date range.
328    pub fn range(&self) -> LseExportRange {
329        self.range
330    }
331
332    /// The JSON body the vault expects.
333    ///
334    /// # Errors
335    /// Returns [`LseError::UnsupportedInterval`] if the timeframe has no provider spelling — a
336    /// state [`new`](Self::new) already rejects, re-checked here rather than unwrapped.
337    fn to_body(&self) -> Result<ExportRequestBody<'_>, LseError> {
338        Ok(ExportRequestBody {
339            dataset: self.dataset.as_catalog_str(),
340            symbol: &self.symbol,
341            timeframe: self.timeframe.as_lse_str()?,
342            start: self.range.start().to_string(),
343            end: self.range.end().to_string(),
344            format: EXPORT_FORMAT,
345        })
346    }
347}
348
349/// The wire shape of an export submission.
350#[derive(Debug, Serialize)]
351struct ExportRequestBody<'a> {
352    dataset: &'a str,
353    symbol: &'a str,
354    timeframe: &'a str,
355    start: String,
356    end: String,
357    format: &'a str,
358}
359
360/// Lifecycle state of an export job.
361///
362/// `queued` and `ready` are measured; `failed` and `expired` are documented by the provider's own
363/// client. [`Other`](Self::Other) preserves anything else rather than collapsing it to a guess.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum LseExportStatus {
366    /// Accepted, not yet building.
367    Queued,
368    /// Building.
369    Running,
370    /// The artifact is available to download.
371    Ready,
372    /// The job will not produce an artifact.
373    Failed,
374    /// The artifact existed and has since been reaped (roughly 48 hours after it was built).
375    Expired,
376    /// A status this integration does not know. Preserved verbatim.
377    Other(String),
378}
379
380impl LseExportStatus {
381    /// Whether polling this job further is pointless.
382    pub fn is_terminal(&self) -> bool {
383        matches!(self, Self::Ready | Self::Failed | Self::Expired)
384    }
385
386    /// The provider's spelling of this status.
387    pub fn as_str(&self) -> &str {
388        match self {
389            Self::Queued => "queued",
390            Self::Running => "running",
391            Self::Ready => "ready",
392            Self::Failed => "failed",
393            Self::Expired => "expired",
394            Self::Other(other) => other,
395        }
396    }
397}
398
399impl From<&str> for LseExportStatus {
400    fn from(value: &str) -> Self {
401        match value {
402            "queued" => Self::Queued,
403            "running" => Self::Running,
404            "ready" => Self::Ready,
405            "failed" => Self::Failed,
406            "expired" => Self::Expired,
407            other => Self::Other(other.to_owned()),
408        }
409    }
410}
411
412impl std::fmt::Display for LseExportStatus {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        f.write_str(self.as_str())
415    }
416}
417
418impl<'de> Deserialize<'de> for LseExportStatus {
419    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
420    where
421        D: serde::Deserializer<'de>,
422    {
423        Ok(Self::from(String::deserialize(deserializer)?.as_str()))
424    }
425}
426
427/// The response to an export submission.
428///
429/// Distinct from [`LseExportJobStatus`] because the provider renames the identifier between the
430/// two responses: `job_id` here, `id` there.
431///
432/// `#[non_exhaustive]` because this mirrors a provider response: the provider may add a field, and
433/// mirroring it here should stay a non-breaking change.
434#[derive(Debug, Clone, Deserialize)]
435#[non_exhaustive]
436pub struct LseExportJob {
437    /// The job identifier, to be passed to [`LseVaultClient::export_status`].
438    pub job_id: String,
439
440    /// Status at submission time — measured as `queued`.
441    pub status: LseExportStatus,
442
443    /// The provider's size estimate.
444    ///
445    /// # ⚠️ Do not act on this
446    /// It is `null` for candle exports, and for tick exports it appears to ignore the symbol and
447    /// date filters entirely: measured at `7,111,419,512` for an artifact that turned out to be
448    /// `596,985` bytes (**11,900×**) and `7,677,490,312` for one of `1,618,641` bytes (**4,700×**).
449    /// Surfaced for completeness only; never use it for preflight, allocation or budgeting.
450    #[serde(default)]
451    pub est_bytes: Option<u64>,
452}
453
454/// The state of an export job, as reported by the status endpoint.
455///
456/// `#[non_exhaustive]` for the same reason as [`LseExportJob`] — it mirrors a provider response.
457#[derive(Debug, Clone, Deserialize)]
458#[non_exhaustive]
459pub struct LseExportJobStatus {
460    /// The job identifier. Note the provider spells this `job_id` on the submit response.
461    pub id: String,
462
463    /// Current lifecycle state.
464    pub status: LseExportStatus,
465
466    /// Row count of the artifact, once known.
467    ///
468    /// # ⚠️ Check this
469    /// A zero-row export is still a well-formed Parquet file carrying the complete schema, so
470    /// "status is ready and the file parses" does **not** imply the request matched anything.
471    #[serde(default)]
472    pub rows: Option<u64>,
473
474    /// Artifact size in bytes, once known. Unlike [`LseExportJob::est_bytes`], this is exact.
475    #[serde(default)]
476    pub bytes: Option<u64>,
477
478    /// Lowercase hex SHA-256 of the artifact, once known.
479    #[serde(default)]
480    pub sha256: Option<String>,
481
482    /// The provider's diagnostic when [`status`](Self::status) is `failed`.
483    #[serde(default)]
484    pub error: Option<String>,
485
486    /// When the artifact is reaped — roughly 48 hours after it is built.
487    #[serde(default)]
488    pub expires_at: Option<DateTime<Utc>>,
489
490    /// The provider's internal source table, e.g. `candles_etf_1d` or `ticks_fx`.
491    #[serde(default)]
492    pub table_name: Option<String>,
493
494    /// What the provider says this job covers, echoed back in its own spelling.
495    ///
496    /// Retained rather than discarded because this provider **silently substitutes defaults for
497    /// parameters it does not recognise**: a misspelled field name earns a `200` and an artifact
498    /// covering something other than what was asked for. These echoes are the only client-side
499    /// evidence of what a job actually covers, so
500    /// [`download_export`](LseVaultClient::download_export) checks each present one against the
501    /// request it was handed. Kept as strings — the provider's own vocabulary — rather than parsed,
502    /// so an unexpected spelling is reported as a mismatch instead of failing to decode a job that
503    /// is otherwise fine.
504    #[serde(default)]
505    pub dataset: Option<String>,
506
507    /// See [`dataset`](Self::dataset).
508    #[serde(default)]
509    pub symbol: Option<String>,
510
511    /// See [`dataset`](Self::dataset).
512    #[serde(default)]
513    pub timeframe: Option<String>,
514
515    /// See [`dataset`](Self::dataset).
516    #[serde(default)]
517    pub start: Option<String>,
518
519    /// See [`dataset`](Self::dataset).
520    #[serde(default)]
521    pub end: Option<String>,
522}
523
524/// A downloaded export artifact, with the provenance needed to decode it safely.
525///
526/// # Why this is not a bare `PathBuf`
527/// An export job targets exactly one dataset, and [`LseDataset::exchange_id`] is total, so **one
528/// file has exactly one [`ExchangeId`]**. Carrying the dataset makes that derivable rather than
529/// re-accepted as an unchecked argument at decode time — which is what turns "rows from a futures
530/// export tagged onto an equities-registered instrument" from a silent misattribution into a
531/// construction-time error.
532///
533/// A caller holding a file obtained out of band constructs one explicitly with
534/// [`new`](Self::new), which puts the obligation in the type rather than in prose.
535#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct LseExport {
537    path: PathBuf,
538    dataset: LseDataset,
539    symbol: String,
540    timeframe: LseExportTimeframe,
541    range: LseExportRange,
542}
543
544impl LseExport {
545    /// Describe an export artifact already on disk.
546    ///
547    /// Prefer [`LseVaultClient::download_export`], which fills this in from the job it downloaded.
548    /// Use this for a file obtained out of band — and note that the dataset you name here is what
549    /// every decoded event's [`ExchangeId`] will be derived from, while `symbol` is what the
550    /// decoder checks every row against.
551    ///
552    /// `timeframe` is taken on the caller's word, because nothing in a Parquet artifact records the
553    /// resolution it was written at. The decoder measures bar spacing against it and rejects a file
554    /// **finer** than declared — 1-minute bars declared `Day1`, where every derived `close_time`
555    /// would stretch over the next 1,439 bars. The opposite mistake is undetectable and the worse
556    /// of the two: daily bars declared `Min1` are spaced far wider than 60 seconds, so they pass,
557    /// and each bar then enters the timeline a minute after its day opened rather than at the end
558    /// of it — a full day of lookahead. See `parquet::read_export`, whose rustdoc states the reach
559    /// of the check in full. (Not linked: that module is behind its own feature, so the link would
560    /// be dead in a build without it.)
561    pub fn new(
562        path: impl Into<PathBuf>,
563        dataset: LseDataset,
564        symbol: impl Into<String>,
565        timeframe: LseExportTimeframe,
566        range: LseExportRange,
567    ) -> Self {
568        Self {
569            path: path.into(),
570            dataset,
571            symbol: symbol.into(),
572            timeframe,
573            range,
574        }
575    }
576
577    /// The artifact's location on disk.
578    pub fn path(&self) -> &Path {
579        &self.path
580    }
581
582    /// The dataset the artifact was exported from.
583    pub fn dataset(&self) -> LseDataset {
584        self.dataset
585    }
586
587    /// The vault display symbol the artifact was exported for.
588    ///
589    /// Every export is single-symbol — the provider offers no multi-symbol spelling — so this is
590    /// the value every row's `symbol` column must carry.
591    pub fn symbol(&self) -> &str {
592        &self.symbol
593    }
594
595    /// The timeframe the artifact contains.
596    pub fn timeframe(&self) -> LseExportTimeframe {
597        self.timeframe
598    }
599
600    /// The date range the artifact covers.
601    pub fn range(&self) -> LseExportRange {
602        self.range
603    }
604
605    /// The venue every event decoded from this artifact is stamped with.
606    pub fn exchange_id(&self) -> ExchangeId {
607        self.dataset.exchange_id()
608    }
609}
610
611impl LseVaultClient {
612    /// Submit an export job.
613    ///
614    /// Returns as soon as the provider accepts the job (`202`); the artifact is built
615    /// asynchronously. Poll with [`export_status`](Self::export_status), or use
616    /// [`await_export`](Self::await_export).
617    ///
618    /// # ⚠️ This spends allowance whether or not it succeeds
619    /// The allowance is five exports per hour and a rejection still consumes one, which is why
620    /// [`LseExportRequest`] validates at construction. Poll
621    /// [`usage`](LseVaultClient::usage) to see where you are — the position is deliberately not
622    /// fetched here, since that would add a network round trip to every submission.
623    ///
624    /// # Errors
625    /// - [`LseError::InvalidInput`] if the range lies wholly in the future. The provider answers
626    ///   such a request with a valid, **empty** artifact and no error, so it spends an export to
627    ///   learn nothing — the same economics as the empty range [`LseExportRange::new`] rejects,
628    ///   which is why it is rejected too. Checked here rather than at construction because the
629    ///   answer depends on when the question is asked; see
630    ///   [`LseExportRange::is_wholly_after`] for the day of slack that keeps a caller's local
631    ///   "today" from tripping it.
632    /// - [`LseError::QuotaExceeded`] when the export allowance is exhausted, carrying the
633    ///   allowance position at the point of rejection.
634    /// - [`LseError::Api`] for any other non-success status, with the provider's diagnostic
635    ///   unwrapped from its envelope.
636    /// - [`LseError::Deserialize`] if the response will not decode.
637    pub async fn submit_export(
638        &self,
639        request: &LseExportRequest,
640    ) -> Result<LseExportJob, LseError> {
641        let today = chrono::Utc::now().date_naive();
642        if request.range().is_wholly_after(today) {
643            return Err(LseError::InvalidInput {
644                message: format!(
645                    "export range {} to {} (end exclusive) lies wholly in the future, and today is \
646                     {today}: the provider would answer with a valid but EMPTY artifact, with no \
647                     error, and still consume one of five hourly exports - check the year",
648                    request.range().start(),
649                    request.range().end()
650                ),
651            });
652        }
653
654        let body = request.to_body()?;
655
656        info!(
657            dataset = body.dataset,
658            symbol = body.symbol,
659            timeframe = body.timeframe,
660            start = body.start,
661            end = body.end,
662            "submitting vault export job; this consumes one of the hourly export allowance"
663        );
664
665        self.post_json("export", &body).await
666    }
667
668    /// Fetch the current state of an export job.
669    ///
670    /// Polling costs nothing from the export allowance — only from the per-minute call budget.
671    ///
672    /// # Errors
673    /// - [`LseError::RateLimited`] on a `429` — deliberately **not**
674    ///   [`LseError::QuotaExceeded`], which is what
675    ///   [`submit_export`](Self::submit_export) and [`download_export`](Self::download_export)
676    ///   report. Those two spend metered allowance (an export, and bytes respectively), so a `429`
677    ///   there means the allowance is gone; a status read spends neither, so a `429` here is the
678    ///   per-minute limit. Reporting it as an exhausted allowance would also add a `usage()` round
679    ///   trip to every iteration of a polling loop, and would assert a provider behaviour that has
680    ///   not been measured on this endpoint.
681    /// - [`LseError::Api`] for any other non-success status, including the `404` an unknown job id
682    ///   returns.
683    /// - [`LseError::Deserialize`] if the response will not decode.
684    pub async fn export_status(&self, job_id: &str) -> Result<LseExportJobStatus, LseError> {
685        self.get_json(&format!("export/{job_id}"), &[]).await
686    }
687
688    /// Poll an export job until it reaches a terminal state.
689    ///
690    /// # Cadence is the caller's
691    /// `poll_interval` and `timeout` are both supplied by the caller and neither is defaulted:
692    /// this integration exposes the allowance signal and never decides pacing on the consumer's
693    /// behalf.
694    ///
695    /// `timeout` bounds the call whatever `poll_interval` is — the sleep is cut short at the deadline
696    /// rather than run in full — so a long interval paired with a short timeout reports
697    /// [`LseError::ExportTimeout`] promptly instead of blocking for an interval first. One status
698    /// request is still in flight when the deadline passes, so the call can overrun it by that
699    /// request's duration. A `poll_interval` or `timeout` so large that it would overflow the clock
700    /// (`Duration::MAX`, used as a "no timeout" / "wait for the deadline" sentinel) saturates rather
701    /// than panicking.
702    ///
703    /// # Errors
704    /// - [`LseError::ExportFailed`] if the job reaches `failed` or `expired`.
705    /// - [`LseError::ExportTimeout`] if `timeout` elapses first. The job keeps building — the
706    ///   identifier stays valid, so a later [`export_status`](Self::export_status) can pick it up
707    ///   without spending another export.
708    /// - Anything [`export_status`](Self::export_status) returns.
709    pub async fn await_export(
710        &self,
711        job_id: &str,
712        poll_interval: Duration,
713        timeout: Duration,
714    ) -> Result<LseExportJobStatus, LseError> {
715        // `Instant + Duration` panics on overflow, and `Duration::MAX` is a plausible "no timeout"
716        // sentinel for a caller to pass. Saturating at a far-future instant makes that mean what the
717        // caller intended rather than aborting the process.
718        let now = tokio::time::Instant::now();
719        let deadline = now
720            .checked_add(timeout)
721            .unwrap_or_else(|| now + Duration::from_secs(86_400 * 365));
722
723        loop {
724            let status = self.export_status(job_id).await?;
725
726            match &status.status {
727                LseExportStatus::Ready => return Ok(status),
728                LseExportStatus::Failed | LseExportStatus::Expired => {
729                    return Err(LseError::ExportFailed {
730                        job_id: job_id.to_owned(),
731                        status: status.status.to_string(),
732                        message: status.error.unwrap_or_default(),
733                    });
734                }
735                pending => {
736                    debug!(job_id, status = %pending, "export job not ready");
737                }
738            }
739
740            if tokio::time::Instant::now() >= deadline {
741                return Err(LseError::ExportTimeout {
742                    job_id: job_id.to_owned(),
743                    status: status.status.to_string(),
744                });
745            }
746
747            // Never sleeps past the deadline, so `timeout` bounds the call whatever `poll_interval`
748            // is. Sleeping the full interval first would let `poll_interval = 60s, timeout = 5s`
749            // block for a minute before reporting a timeout it had already passed.
750            //
751            // `checked_add` for the same reason as `deadline` above, and it is NOT covered by that
752            // one: `.min(deadline)` clamps the result, but the addition producing it is evaluated
753            // first, so a bare `+` here would abort the caller's task on a `Duration::MAX`
754            // `poll_interval` — the "poll once, then wait for the deadline" spelling, and no less
755            // plausible than the same sentinel for `timeout`. An interval that cannot be
756            // represented is longer than any deadline, so falling back to the deadline is what it
757            // means.
758            let next_poll = tokio::time::Instant::now()
759                .checked_add(poll_interval)
760                .map_or(deadline, |instant| deadline.min(instant));
761            tokio::time::sleep_until(next_poll).await;
762        }
763    }
764
765    /// Download a `ready` export artifact to `destination`.
766    ///
767    /// # Transfer bound
768    /// The shared client applies a 30 s **total** deadline, which `reqwest` runs through to the end
769    /// of the response body — right for a page of JSON, fatal for an artifact, since a transfer
770    /// longer than 30 s would abort mid-body however healthy the connection. This request therefore
771    /// overrides it with a six-hour backstop (enough for a 7 GB artifact on a ~3 Mbit/s link) and
772    /// relies on the client's **per-read** timeout to detect an actual stall in seconds. An
773    /// interrupted transfer resumes rather than restarting, so a slower link than that still
774    /// converges across calls.
775    ///
776    /// # Resume and integrity
777    /// Downloads into `destination` + `.<job id>.part`, resuming an interrupted transfer with a
778    /// `Range` request (the provider advertises `Accept-Ranges: bytes` and answers `206`). The
779    /// in-progress file is scoped to the **job**, not to the destination alone, so a leftover can
780    /// only ever be a prefix of the artifact this call is fetching — one job never resumes onto
781    /// another's bytes. A `206` is
782    /// accepted only if its `Content-Range` starts at the byte that was asked for; a `416` is
783    /// treated as "the `.part` already holds the whole artifact" rather than as a failure, so a
784    /// transfer interrupted between the final write and the rename still converges. On completion
785    /// the artifact is verified against the job's `sha256` and `bytes`, then atomically renamed
786    /// into place. **On a mismatch an error is returned and the destination is left untouched**,
787    /// so it never contains a corrupt file.
788    ///
789    /// **Verification covers only the dimensions the job actually reports.** Both fields are
790    /// [`Option`], and every measured `ready` job carried both — but that is an observation, not a
791    /// guarantee the provider makes. A `ready` job missing one is downloaded and renamed with that
792    /// check skipped, and a `warn!` naming the skipped check is emitted. Skipping is deliberate:
793    /// refusing an artifact the provider considers ready, over metadata this integration merely
794    /// expects, would forbid a correct download with no way around it. A job reporting **neither**
795    /// field additionally ignores any pre-existing `.part` and transfers the artifact in full, so
796    /// what lands at `destination` is at least something this call fetched end to end rather than a
797    /// leftover accepted on the strength of its filename. A download consumes no export allowance,
798    /// so that costs bandwidth only.
799    ///
800    /// # ⚠️ Caller obligation: one download per destination at a time
801    /// Concurrent calls sharing a `destination` are **not supported**. Each reads the `.part` file's
802    /// length to decide where to resume from, then opens it; two overlapping calls interleave writes
803    /// against independently-primed hashers, corrupting the `.part` or failing verification
804    /// spuriously. Serialise them, or give each its own destination.
805    ///
806    /// A `.part` that failed verification is **kept when it is incomplete and discarded when it is
807    /// corrupt**, which is decided by *which* check failed rather than by whether this call happened
808    /// to append anything. Shorter than the artifact means a truncated transfer, so the bytes are a
809    /// valid prefix and a re-call resumes from them — for a multi-gigabyte artifact that is the
810    /// difference between finishing and starting over. Longer than the artifact, or the right length
811    /// at the wrong digest with nothing left to fetch, means corrupt: the file is removed so a
812    /// re-call restarts rather than failing identically forever. [`LseError::IntegrityMismatch`]
813    /// reports which happened via `discarded`.
814    ///
815    /// # ⚠️ Caller obligation: reclaiming abandoned `.part` files
816    /// **Nothing here ever deletes a `.part` except the corrupt-file path above.** A `.part` is
817    /// named for its job (see the resume section), which is what makes resuming safe — and what
818    /// makes an abandoned one unreachable: a process killed mid-transfer leaves its `.part` behind,
819    /// and a *re-export* of the same range gets a new job id, so the next call writes a new file
820    /// and never looks at the old one. A 7 GB export killed at 6 GB occupies 6 GB indefinitely.
821    ///
822    /// This integration deliberately offers no cleanup or enumeration API: deleting files it did
823    /// not write this call is a policy decision, and "which of these is still wanted" depends on
824    /// whether the caller intends to resume — which only the caller knows. Reclaiming them is
825    /// therefore yours. They are identifiable without help: they sit next to the destination, so a
826    /// glob of `<destination>.*.part` finds every candidate. Matching a *specific* job needs one
827    /// step of care — the id embedded in the name is the job id with every character outside
828    /// `[A-Za-z0-9._-]` replaced by `_`, since a provider-supplied id is not assumed path-safe — so
829    /// apply the same substitution before deciding which ids to spare.
830    ///
831    /// Downloads consume no export allowance, so discarding a `.part` you no longer want costs
832    /// bandwidth on the next attempt and nothing else.
833    ///
834    /// The URL is built from the client's base URL and the job id rather than from the job's
835    /// `download_url`, preserving this client's invariant that it only ever requests URLs it
836    /// constructed itself.
837    ///
838    /// # ⚠️ A `ready` job may legitimately contain zero rows
839    /// Check [`LseExportJobStatus::rows`]. A zero-row artifact is a well-formed Parquet file with
840    /// the full schema, so nothing downstream will complain.
841    ///
842    /// # Errors
843    /// - [`LseError::InvalidInput`] if the job is not `ready`.
844    /// - [`LseError::IntegrityMismatch`] if the downloaded bytes do not match the job's `sha256`
845    ///   or `bytes`.
846    /// - [`LseError::Io`] for any filesystem failure.
847    /// - [`LseError::QuotaExceeded`] / [`LseError::Api`] as for
848    ///   [`submit_export`](Self::submit_export).
849    pub async fn download_export(
850        &self,
851        job: &LseExportJobStatus,
852        destination: impl AsRef<Path>,
853        request: &LseExportRequest,
854    ) -> Result<LseExport, LseError> {
855        if job.status != LseExportStatus::Ready {
856            return Err(LseError::InvalidInput {
857                message: format!(
858                    "export job {} is {}, not ready; nothing to download",
859                    job.id, job.status
860                ),
861            });
862        }
863
864        verify_job_covers_request(job, request)?;
865
866        let destination = destination.as_ref();
867        let part = part_path(destination, &job.id);
868
869        if let Some(parent) = destination
870            .parent()
871            .filter(|parent| !parent.as_os_str().is_empty())
872        {
873            tokio::fs::create_dir_all(parent)
874                .await
875                .map_err(|source| LseError::Io {
876                    message: format!("creating {}", parent.display()),
877                    source,
878                })?;
879        }
880
881        // A job reporting neither `bytes` nor `sha256` offers nothing to verify against, so a
882        // pre-existing `.part` cannot be checked even once it is complete — resuming onto it would
883        // rename an unverified file into place on the strength of its name alone, having fetched
884        // none of it. Re-downloading costs bandwidth and **no allowance** (the five-per-hour limit
885        // is on export *submits*), so the leftover is ignored and the transfer restarts. Every
886        // measured `ready` job reported both fields, so this is the unmeasured path rather than the
887        // normal one.
888        let verifiable = job.bytes.is_some() || job.sha256.is_some();
889
890        // Hash the already-downloaded prefix so a resumed transfer still verifies end to end.
891        let (mut hasher, mut downloaded) = if verifiable {
892            hash_existing_part(&part).await?
893        } else {
894            (Sha256::new(), 0)
895        };
896
897        // A `.part` that already holds the whole artifact means a previous run was interrupted
898        // between the final write and the rename. Re-requesting would only earn a 416. The
899        // `downloaded > 0` guard keeps a job reporting `bytes: 0` from satisfying this vacuously
900        // when no `.part` exists at all, which would skip the fetch and then rename a file that was
901        // never created.
902        let mut complete = downloaded > 0 && job.bytes.is_some_and(|total| downloaded >= total);
903
904        if !complete {
905            let transferred = self.transfer_export(job, &part, hasher, downloaded).await?;
906
907            hasher = transferred.hasher;
908            downloaded = transferred.downloaded;
909            // `transfer_export` cannot tell a finished stream from a truncated one on its own, so
910            // it only ever reports `exhausted` for the `416` that proves there is nothing left. The
911            // byte count settles the remaining case here, where `job.bytes` is in scope: a transfer
912            // that produced the whole artifact is exhausted whether or not the server said so, and
913            // treating it as resumable would keep a wrong-digest file that no further request can
914            // ever repair.
915            complete = transferred.exhausted
916                || job
917                    .bytes
918                    .is_some_and(|total| transferred.downloaded >= total);
919        }
920
921        let digest = hex::encode(hasher.finalize());
922
923        // The `.part` is scoped to this job (see `part_path`), so a leftover can only ever be a
924        // prefix of *this* artifact. What to do with a file that failed verification therefore
925        // follows from **which** check failed, rather than from the weaker "did this call append
926        // anything?" question:
927        //
928        // - **Shorter than the artifact**: the transfer was truncated — a dropped connection, a
929        //   closed stream. Those bytes are a valid prefix, so the file is KEPT and a re-call resumes
930        //   from it. For a multi-gigabyte artifact that is the difference between finishing and
931        //   starting over, and truncation is the common failure, not the rare one.
932        // - **Longer than the artifact**: it cannot be a prefix of anything, so it is corrupt.
933        //   DISCARDED, so a re-call restarts rather than failing identically forever.
934        // - **Right length (or no length reported), wrong digest**: with nothing left to fetch —
935        //   `complete`, i.e. the file already looked whole or the server answered the resume `Range`
936        //   with a `416` — the bytes on disk are all there will ever be, so they are corrupt and are
937        //   DISCARDED. Otherwise this call did append, and when no byte count was reported a short
938        //   read is indistinguishable from corruption: the file is KEPT so a re-call can resume, and
939        //   the call after that converges on the `416`.
940        //
941        // Discarding only ever removes a file this integration is the sole writer of.
942        if let Some(expected) = job.bytes.filter(|expected| *expected != downloaded) {
943            let corrupt = downloaded > expected;
944            return Err(self
945                .integrity_mismatch(
946                    &part,
947                    format!("{expected} bytes"),
948                    format!("{downloaded} bytes"),
949                    corrupt,
950                )
951                .await);
952        }
953
954        if let Some(expected) = job
955            .sha256
956            .as_ref()
957            .filter(|expected| !expected.eq_ignore_ascii_case(&digest))
958        {
959            return Err(self
960                .integrity_mismatch(&part, expected.clone(), digest, complete)
961                .await);
962        }
963
964        // Verification is only as complete as the job's metadata. Every measured `ready` job
965        // reported both fields, so this should not fire — which is exactly why it is worth saying
966        // out loud if it ever does, rather than renaming an unverified artifact silently.
967        if job.bytes.is_none() || job.sha256.is_none() {
968            warn!(
969                job_id = job.id,
970                path = %destination.display(),
971                checked_length = job.bytes.is_some(),
972                checked_sha256 = job.sha256.is_some(),
973                "export job reports no integrity metadata for one or both checks; accepting the \
974                 artifact with that verification skipped"
975            );
976        }
977
978        tokio::fs::rename(&part, destination)
979            .await
980            .map_err(|source| LseError::Io {
981                message: format!("renaming {} to {}", part.display(), destination.display()),
982                source,
983            })?;
984
985        info!(
986            job_id = job.id,
987            path = %destination.display(),
988            bytes = downloaded,
989            rows = job.rows.unwrap_or_default(),
990            "export downloaded and verified"
991        );
992
993        Ok(LseExport::new(
994            destination,
995            request.dataset(),
996            request.symbol(),
997            request.timeframe(),
998            request.range(),
999        ))
1000    }
1001
1002    /// Fetch whatever of `job`'s artifact is not already in `part`, appending to it.
1003    ///
1004    /// `hasher` must already cover the `downloaded` bytes on disk, so that a resumed transfer still
1005    /// verifies end to end.
1006    ///
1007    /// Split out of [`download_export`](Self::download_export) so that the decision of *what* to do
1008    /// with the resulting file — resume, discard, rename — reads on its own, apart from the
1009    /// mechanics of getting the bytes.
1010    ///
1011    /// # Errors
1012    /// [`LseError::QuotaExceeded`] on a `429`, [`LseError::Api`] for any other non-success status,
1013    /// [`LseError::Http`] for a transport failure mid-stream, and [`LseError::Io`] for a filesystem
1014    /// failure. In every case the `.part` is left as it stands; the caller decides its fate.
1015    async fn transfer_export(
1016        &self,
1017        job: &LseExportJobStatus,
1018        part: &Path,
1019        mut hasher: Sha256,
1020        mut downloaded: u64,
1021    ) -> Result<Transferred, LseError> {
1022        // Held for the whole transfer, body included: an artifact download occupies a connection
1023        // for as long as it runs, so counting it only while its headers are in flight would let
1024        // the client exceed the provider's concurrency ceiling for hours.
1025        let permit = self.enter_gate().await;
1026
1027        let url = format!("{}/export/{}/download", self.base_url(), job.id);
1028        // Overrides the client's total deadline for this request only; see `DOWNLOAD_TIMEOUT`.
1029        // `identity` because the transfer must be byte-exact against the artifact as stored. The
1030        // client otherwise advertises gzip and decodes it transparently, which would count
1031        // `downloaded` in decoded bytes while a `Range` addresses the *encoded* representation
1032        // (RFC 9110 §14.1.2), so a resume would ask for the wrong offset of a stream it cannot
1033        // decode from the middle. reqwest does not drop gzip for `Range` requests on its own.
1034        // This relies on the server honouring the request: the client decodes any response that
1035        // arrives gzip-encoded regardless of what was asked for. A server that ignored it would
1036        // still verify on a full transfer, and on a resume fail loudly with a decode error rather
1037        // than write a corrupt file.
1038        let mut builder = self
1039            .http()
1040            .get(&url)
1041            .timeout(DOWNLOAD_TIMEOUT)
1042            .header(reqwest::header::ACCEPT_ENCODING, "identity");
1043        if downloaded > 0 {
1044            builder = builder.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
1045        }
1046
1047        let response = builder.send().await?;
1048        let status = response.status();
1049
1050        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1051            // Released before `quota_exceeded`, which issues a `usage` call that takes a permit of
1052            // its own: holding this one across it would deadlock a client built with
1053            // `with_concurrency(1)`.
1054            drop(permit);
1055            return Err(self.quota_exceeded().await);
1056        }
1057
1058        // A `416` answers a `Range` starting at or past the artifact's length: the `.part` already
1059        // holds everything the server has. That is precisely the case the caller's `complete` check
1060        // cannot decide for itself, because it needs `job.bytes` and the job is entitled to report
1061        // `None` — verification tolerates that absence, so resuming must too. Failing here instead
1062        // would make the documented "re-calling resumes" never converge: every retry would
1063        // re-request the same unsatisfiable range. Whether those bytes are actually *this* job's
1064        // artifact is still decided by the caller's integrity checks.
1065        if downloaded > 0 && status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
1066            warn!(
1067                job_id = job.id,
1068                downloaded,
1069                "range request rejected as unsatisfiable; the existing part file already holds the \
1070                 whole artifact"
1071            );
1072
1073            return Ok(Transferred {
1074                hasher,
1075                downloaded,
1076                exhausted: true,
1077            });
1078        }
1079
1080        if !status.is_success() {
1081            let body = read_body_capped(response, MAX_ERROR_BODY_DOWNLOAD_BYTES).await?;
1082            return Err(LseError::Api {
1083                status: status.as_u16(),
1084                message: extract_detail(&body),
1085            });
1086        }
1087
1088        // The server is entitled to ignore `Range` and answer `200` with the whole artifact.
1089        // Restarting is then the only correct response — appending would duplicate the prefix.
1090        let resuming = downloaded > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
1091        if resuming {
1092            verify_resume_offset(&response, downloaded)?;
1093        }
1094        if downloaded > 0 && !resuming {
1095            warn!(
1096                job_id = job.id,
1097                downloaded, "range request answered in full; restarting the download"
1098            );
1099            hasher = Sha256::new();
1100            downloaded = 0;
1101        }
1102
1103        let file = tokio::fs::OpenOptions::new()
1104            .create(true)
1105            .write(true)
1106            .truncate(!resuming)
1107            .append(resuming)
1108            .open(part)
1109            .await
1110            .map_err(|source| LseError::Io {
1111                message: format!("opening {}", part.display()),
1112                source,
1113            })?;
1114
1115        // Buffered because `tokio::fs` dispatches each write to the blocking pool: writing every
1116        // HTTP chunk straight through costs one round trip per chunk, and artifacts run to
1117        // gigabytes. An interrupted transfer loses at most one buffer of resume progress — the next
1118        // call re-reads the `.part`'s real on-disk length, so it resumes correctly from whatever
1119        // landed, just slightly further back.
1120        let mut file = tokio::io::BufWriter::with_capacity(DOWNLOAD_BUFFER_BYTES, file);
1121
1122        let mut stream = response.bytes_stream();
1123        while let Some(chunk) = stream.next().await {
1124            let chunk = chunk?;
1125            hasher.update(&chunk);
1126            downloaded += chunk.len() as u64;
1127            file.write_all(&chunk)
1128                .await
1129                .map_err(|source| LseError::Io {
1130                    message: format!("writing {}", part.display()),
1131                    source,
1132                })?;
1133
1134            // Stop as soon as the response outruns the length the job reported. Without this the
1135            // only bound on the body is the six-hour deadline: a misbehaving proxy streaming
1136            // 500 GB against a `bytes: 1_000_000` job fills the disk before the length check in
1137            // `download_export` ever runs, and on `ENOSPC` the write fails with the oversized
1138            // `.part` RETAINED — the one state that is neither resumable nor cleaned up.
1139            //
1140            // The overshoot is bounded by one chunk, and the chunk is written before the break on
1141            // purpose: `downloaded` then matches the file's real length, so the caller sees
1142            // `downloaded > expected` and takes its "longer than the artifact, therefore corrupt"
1143            // branch, which discards the `.part` and says so. Breaking before the write would land
1144            // on exactly `expected` bytes and report a digest mismatch instead — the same outcome
1145            // under a diagnosis that sends the reader after the wrong problem.
1146            //
1147            // A job reporting no `bytes` has nothing to bound against; that path stays as it was.
1148            if job.bytes.is_some_and(|total| downloaded > total) {
1149                warn!(
1150                    job_id = job.id,
1151                    downloaded,
1152                    expected = job.bytes,
1153                    "export download exceeded the length the job reports; abandoning the transfer \
1154                     rather than writing an unbounded body to disk"
1155                );
1156                break;
1157            }
1158        }
1159
1160        file.flush().await.map_err(|source| LseError::Io {
1161            message: format!("flushing {}", part.display()),
1162            source,
1163        })?;
1164
1165        Ok(Transferred {
1166            hasher,
1167            downloaded,
1168            // The stream ended, but nothing observed *here* distinguishes a complete artifact from
1169            // a truncated one — only the `416` above proves exhaustion. Reported as not exhausted
1170            // so that a digest failure keeps the `.part` for a resume; `download_export` overrides
1171            // this when the job's byte count shows the transfer already produced the whole
1172            // artifact, and otherwise the call after next converges on the `416`.
1173            exhausted: false,
1174        })
1175    }
1176
1177    /// Issue an authenticated `POST` against a vault path and deserialise the JSON body.
1178    ///
1179    /// Accepts `202` as success — the export endpoint's normal answer.
1180    ///
1181    /// Rationed by the client's shared gate, exactly as `GET`s are: an export submission spends
1182    /// the same allowance a candle page does.
1183    ///
1184    /// # Errors
1185    /// Maps a `429` to [`LseError::QuotaExceeded`], any other non-success status to
1186    /// [`LseError::Api`], and an undecodable body to [`LseError::Deserialize`].
1187    pub(crate) async fn post_json<B, T>(&self, path: &str, body: &B) -> Result<T, LseError>
1188    where
1189        B: Serialize + ?Sized,
1190        T: serde::de::DeserializeOwned,
1191    {
1192        let permit = self.enter_gate().await;
1193
1194        let url = format!("{}/{path}", self.base_url());
1195        let response = self.http().post(&url).json(body).send().await?;
1196        let status = response.status();
1197
1198        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1199            // See `transfer_export`: `quota_exceeded` takes a permit of its own, so this one must
1200            // go first or a single-permit client deadlocks.
1201            drop(permit);
1202            return Err(self.quota_exceeded().await);
1203        }
1204
1205        if !status.is_success() {
1206            let body = read_body_capped(response, MAX_ERROR_BODY_DOWNLOAD_BYTES).await?;
1207            return Err(LseError::Api {
1208                status: status.as_u16(),
1209                message: extract_detail(&body),
1210            });
1211        }
1212
1213        let body = response.text().await?;
1214        debug!(len = body.len(), path, "vault response received");
1215
1216        serde_json::from_str(&body).map_err(|error| LseError::Deserialize {
1217            message: format!("{path}: {error}"),
1218        })
1219    }
1220
1221    /// Build a [`LseError::IntegrityMismatch`], discarding the partial file when it cannot be
1222    /// resumed from.
1223    ///
1224    /// `discard` is set when the file is **corrupt** rather than merely **incomplete**: longer than
1225    /// the artifact, or the right length at the wrong digest with nothing left to fetch. An
1226    /// incomplete file is a valid prefix and is kept, so a re-call resumes from it instead of
1227    /// re-transferring everything. A removal failure is logged rather than replacing the integrity
1228    /// error, which is the more useful diagnostic of the two.
1229    async fn integrity_mismatch(
1230        &self,
1231        part: &Path,
1232        expected: String,
1233        actual: String,
1234        discard: bool,
1235    ) -> LseError {
1236        if discard {
1237            warn!(
1238                path = %part.display(),
1239                %expected,
1240                %actual,
1241                "the partial file is corrupt rather than incomplete -- it is longer than the \
1242                 artifact, or complete at the wrong digest -- so it cannot be resumed; discarding \
1243                 it so a re-call restarts"
1244            );
1245
1246            if let Err(error) = tokio::fs::remove_file(part).await {
1247                warn!(
1248                    path = %part.display(),
1249                    %error,
1250                    "could not remove the unusable partial file; remove it by hand, or this job \
1251                     will keep failing verification"
1252                );
1253            }
1254        }
1255
1256        LseError::IntegrityMismatch {
1257            path: part.to_path_buf(),
1258            expected,
1259            actual,
1260            discarded: discard,
1261        }
1262    }
1263
1264    /// Build a [`LseError::QuotaExceeded`] describing where the allowance stands.
1265    ///
1266    /// The export endpoints answer a `429` with no `Retry-After` and no rate-limit headers, so the
1267    /// position has to be fetched. A failed follow-up yields `status: None` rather than a fabricated
1268    /// [`QuotaStatus`](super::quota::QuotaStatus) — and, importantly, **still**
1269    /// [`LseError::QuotaExceeded`], so a caller pacing itself off that variant sees every exhausted
1270    /// allowance rather than silently missing the ones where the follow-up call happened to fail too.
1271    async fn quota_exceeded(&self) -> LseError {
1272        let status = match self.usage().await {
1273            Ok(status) => Some(status),
1274            Err(error) => {
1275                warn!(
1276                    %error,
1277                    "export allowance exhausted, and the follow-up usage call failed; reporting \
1278                     the rejection without an allowance position"
1279                );
1280                None
1281            }
1282        };
1283
1284        LseError::QuotaExceeded { status }
1285    }
1286}
1287
1288/// What one call to [`LseVaultClient::transfer_export`] left on disk.
1289struct Transferred {
1290    /// Advanced over every byte of the `.part`, prefix included, so the digest is end to end.
1291    hasher: Sha256,
1292    /// The `.part`'s length afterwards.
1293    downloaded: u64,
1294    /// `true` when the server confirmed there is nothing left to fetch, which is the one thing a
1295    /// byte count cannot say when the job reports none. It decides whether a later digest failure
1296    /// means "corrupt, discard" or "truncated, keep and resume".
1297    exhausted: bool,
1298}
1299
1300/// Check that `job` describes `request`, on every dimension the provider echoed back.
1301///
1302/// This provider **silently substitutes defaults for parameters it does not recognise**, so a
1303/// misspelled field earns a `200` and an artifact covering something else — the failure mode a
1304/// wiremock test already pins for the `timeframe` name. The status response echoes `dataset`,
1305/// `symbol`, `timeframe`, `start` and `end`, and that echo is the only client-side evidence of what a
1306/// job actually covers. Checking it here turns "decoded the wrong instrument's tape" into an error at
1307/// the point the artifact is claimed, rather than a silent misattribution downstream.
1308///
1309/// A field the provider omits is not checked: absence is not disagreement, and refusing to download
1310/// over metadata the provider never promised would forbid a correct download. `symbol` is compared
1311/// case-insensitively — the provider publishes symbols uppercase but echoes back what was sent.
1312///
1313/// # ⚠️ `start`/`end` are compared as strings, against [`NaiveDate`]'s `Display`
1314/// This assumes the provider echoes dates as `YYYY-MM-DD`, which is measured but not guaranteed.
1315/// The assumption **fails safe**: a provider that switched to `DD/MM/YYYY` or added a time
1316/// component would make every comparison unequal, so the job is rejected as a mismatch rather than
1317/// accepted on a bad match. That is over-strict, not unsound — a correct download would start
1318/// failing loudly, and the fix is to parse both sides rather than to relax the check. Parsing is
1319/// not done today because the *purpose* here is detecting a substituted default, and an
1320/// unparseable echo is itself evidence of one.
1321///
1322/// [`NaiveDate`]: chrono::NaiveDate
1323fn verify_job_covers_request(
1324    job: &LseExportJobStatus,
1325    request: &LseExportRequest,
1326) -> Result<(), LseError> {
1327    let mismatch = |field: &str, requested: String, reported: &str| LseError::ExportJobMismatch {
1328        job_id: job.id.clone(),
1329        field: field.to_owned(),
1330        requested,
1331        reported: reported.to_owned(),
1332    };
1333
1334    if let Some(reported) = job.dataset.as_deref() {
1335        let requested = request.dataset().as_catalog_str();
1336        if reported != requested {
1337            return Err(mismatch("dataset", requested.to_owned(), reported));
1338        }
1339    }
1340
1341    if let Some(reported) = job.symbol.as_deref()
1342        && !reported.eq_ignore_ascii_case(request.symbol())
1343    {
1344        return Err(mismatch("symbol", request.symbol().to_owned(), reported));
1345    }
1346
1347    // `as_lse_str` is fallible only for a resolution this integration cannot express, which
1348    // `LseExportRequest::new` already rejected -- so a request in hand always has one.
1349    if let Some(reported) = job.timeframe.as_deref()
1350        && let Ok(requested) = request.timeframe().as_lse_str()
1351        && reported != requested
1352    {
1353        return Err(mismatch("timeframe", requested.to_owned(), reported));
1354    }
1355
1356    if let Some(reported) = job.start.as_deref() {
1357        let requested = request.range().start().to_string();
1358        if reported != requested {
1359            return Err(mismatch("start", requested, reported));
1360        }
1361    }
1362
1363    if let Some(reported) = job.end.as_deref() {
1364        let requested = request.range().end().to_string();
1365        if reported != requested {
1366            return Err(mismatch("end", requested, reported));
1367        }
1368    }
1369
1370    Ok(())
1371}
1372
1373/// The in-progress filename for one **job's** artifact: `x.parquet` becomes `x.parquet.<job>.part`.
1374///
1375/// Appends to the whole filename rather than replacing the extension, so it never collides with a
1376/// sibling of a different type.
1377///
1378/// Scoped to the job rather than to the destination alone, which makes "a leftover in-progress file
1379/// is a prefix of the artifact being fetched" an invariant of the *name*. Sharing one `.part` across
1380/// jobs left that as an inference from whether the call had appended bytes — a test that cannot tell
1381/// this job's prefix from a different job's, so a `.part` from an earlier job at the same destination
1382/// was resumed onto, corrupting the result and costing a billed download to discover it.
1383///
1384/// The job id is an opaque provider token (measured: UUID-shaped). Any character outside
1385/// `[A-Za-z0-9._-]` is replaced with `_`, so an id cannot introduce path components or escape the
1386/// destination's directory.
1387fn part_path(destination: &Path, job_id: &str) -> PathBuf {
1388    let mut name = destination.as_os_str().to_owned();
1389    name.push(".");
1390    name.push(
1391        job_id
1392            .chars()
1393            .map(|character| {
1394                if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
1395                    character
1396                } else {
1397                    '_'
1398                }
1399            })
1400            .collect::<String>(),
1401    );
1402    name.push(".part");
1403    PathBuf::from(name)
1404}
1405
1406/// Confirm a `206` resumes from exactly the offset that was requested.
1407///
1408/// A `206` starting anywhere else gets appended to the `.part` as though it continued it, leaving a
1409/// file that is neither the artifact nor a resumable prefix of it. The `bytes` and `sha256` checks
1410/// would normally catch that — but both are `Option` on the job, so a `ready` job reporting neither
1411/// would rename the corrupt result into place. Checking the header closes that gap where the
1412/// mistake is made, and says what actually went wrong instead of "digest mismatch".
1413///
1414/// A `206` carrying no parseable `Content-Range` is itself a protocol violation (RFC 9110 §15.3.7
1415/// requires the header), so it is surfaced rather than assumed benign.
1416fn verify_resume_offset(response: &reqwest::Response, expected_start: u64) -> Result<(), LseError> {
1417    let status = reqwest::StatusCode::PARTIAL_CONTENT.as_u16();
1418
1419    let header = response
1420        .headers()
1421        .get(reqwest::header::CONTENT_RANGE)
1422        .and_then(|value| value.to_str().ok())
1423        .ok_or_else(|| LseError::Api {
1424            status,
1425            message: "partial-content response carries no usable Content-Range header".to_owned(),
1426        })?;
1427
1428    // `bytes <first>-<last>/<complete-length|*>`. Only `<first>` decides where these bytes belong.
1429    let first = header
1430        .trim()
1431        .strip_prefix("bytes ")
1432        .and_then(|range| range.split('-').next())
1433        .and_then(|first| first.trim().parse::<u64>().ok())
1434        .ok_or_else(|| LseError::Api {
1435            status,
1436            message: format!("unparseable Content-Range on a partial-content response: {header}"),
1437        })?;
1438
1439    if first != expected_start {
1440        return Err(LseError::Api {
1441            status,
1442            message: format!(
1443                "partial-content response resumes at byte {first}, not the requested \
1444                 {expected_start}"
1445            ),
1446        });
1447    }
1448
1449    Ok(())
1450}
1451
1452/// Read an existing `.part` back into a hasher, returning it and the byte count.
1453///
1454/// A resumed download must hash the bytes it did not fetch, or the final digest is meaningless.
1455async fn hash_existing_part(part: &Path) -> Result<(Sha256, u64), LseError> {
1456    let mut hasher = Sha256::new();
1457
1458    let mut file = match tokio::fs::File::open(part).await {
1459        Ok(file) => file,
1460        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((hasher, 0)),
1461        Err(source) => {
1462            return Err(LseError::Io {
1463                message: format!("opening {}", part.display()),
1464                source,
1465            });
1466        }
1467    };
1468
1469    let mut total = 0_u64;
1470    let mut buffer = vec![0_u8; HASH_READ_BUFFER_BYTES];
1471    loop {
1472        let read = file
1473            .read(&mut buffer)
1474            .await
1475            .map_err(|source| LseError::Io {
1476                message: format!("reading {}", part.display()),
1477                source,
1478            })?;
1479        if read == 0 {
1480            break;
1481        }
1482        hasher.update(&buffer[..read]);
1483        total += read as u64;
1484    }
1485
1486    debug!(path = %part.display(), bytes = total, "resuming a partial export download");
1487
1488    Ok((hasher, total))
1489}