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}