Skip to main content

cortex/backend/
export.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! HTML dataset export — one parameterized exporter that collapses the two out-of-band admin
9//! scripts (`scripts/bundle-html-dataset.sh`, grouped per year-month, and
10//! `scripts/bundle-html-dataset-by-severity.sh`, grouped per severity) into a single,
11//! dependency-free implementation (no `psql`/`unzip`/`zip`/`egrep` — the `zip` crate is already
12//! vendored, so a fresh box needs nothing extra).
13//!
14//! Both scripts do the same thing: select a corpus/service's tasks of a given severity, pull the
15//! main `*.html` out of each task's result archive (`<entry-dir>/<service>.zip`), and bundle the
16//! per-paper HTML into ZIP archives. They differ only in **how the papers are bucketed into
17//! archives** — by month or by severity — which is the single [`GroupBy`] knob here. The scripts'
18//! one naming inconsistency (`no_problem` vs `no-problem` for the same severity) is resolved in
19//! favour of the canonical [`TaskStatus::to_key`] spelling (`no_problem`).
20
21use std::fs::{self, File};
22use std::io::{Read, Write};
23use std::path::PathBuf;
24
25use diesel::PgConnection;
26use diesel::prelude::*;
27
28use crate::helpers::{TaskStatus, result_archive_path};
29use crate::models::{Corpus, Service};
30
31/// How the per-paper HTML files are bucketed into output archives — the sole difference between the
32/// two scripts this replaces.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum GroupBy {
35  /// One archive per year-month (`<corpus>-<yymm>.zip`, papers at `<yymm>/<paper>.html`) — the
36  /// `bundle-html-dataset.sh` layout.
37  Month,
38  /// One archive per severity (`<corpus>-<severity>.zip`, papers at
39  /// `<severity>/<yymm>/<paper>.html`) — the `bundle-html-dataset-by-severity.sh` layout.
40  Severity,
41}
42
43impl GroupBy {
44  /// Parse the CLI/string form (`month` / `severity`).
45  pub fn from_key(key: &str) -> Option<Self> {
46    match key {
47      "month" => Some(GroupBy::Month),
48      "severity" => Some(GroupBy::Severity),
49      _ => None,
50    }
51  }
52}
53
54/// One produced dataset archive and how many papers it holds.
55#[derive(Debug, serde::Serialize)]
56pub struct DatasetArchive {
57  /// archive file name, e.g. `arxmliv-1808.zip`
58  pub name: String,
59  /// number of HTML documents bundled into it
60  pub entries: usize,
61}
62
63/// The result of an [`export_html_dataset`] run — the archives written plus tallies, also the body
64/// of the `*-manifest.json` provenance file written alongside them.
65#[derive(Debug, serde::Serialize)]
66pub struct DatasetExportOutcome {
67  /// corpus the dataset was carved from
68  pub corpus: String,
69  /// service whose HTML output was bundled
70  pub service: String,
71  /// `month` or `severity`
72  pub group_by: String,
73  /// per-archive size cap (MB) if chunking was enabled, else `None` (one archive per bucket).
74  /// Recorded so a resumer uses the same limit (boundaries are limit-dependent).
75  pub max_archive_mb: Option<u64>,
76  /// the severity keys included (canonical spelling)
77  pub severities: Vec<String>,
78  /// RFC-3339 UTC time the export finished
79  pub generated_at: String,
80  /// the `cortex` version that produced it
81  pub cortex_version: String,
82  /// archives written, in name order
83  pub archives: Vec<DatasetArchive>,
84  /// total HTML documents bundled across all archives
85  pub total_entries: usize,
86  /// tasks whose result archive was missing/unreadable or had no HTML — counted, not bundled
87  pub skipped: usize,
88}
89
90/// Keyset-pagination page size for the entry scan: at most this many `entry` paths are resident at
91/// once (then streamed into archives and dropped). With the per-archive streaming below, that makes
92/// the exporter's footprint O(one page + one open zip + one paper's HTML) regardless of corpus size
93/// — the fix for the old whole-work-list-in-RAM `BTreeMap` (KNOWN_ISSUES E-3).
94const EXPORT_PAGE_SIZE: i64 = 10_000;
95
96/// The shared ZIP entry options for every dataset archive (max-deflate, matching the scripts).
97fn zip_options() -> zip::write::FileOptions<'static, ()> {
98  zip::write::FileOptions::default()
99    .compression_method(zip::CompressionMethod::Deflated)
100    .compression_level(Some(9))
101}
102
103/// Export a corpus/service's converted HTML into ZIP archives bucketed by [`GroupBy`].
104///
105/// Reads existing result archives off the shared filesystem (no conversion is run); the DB is only
106/// queried for the matching task `entry` paths. `progress` is called with human-readable milestone
107/// lines (the CLI prints them; a future web/job caller can stream them). Resumable: an archive
108/// whose `.zip` already exists in `out_dir` is left untouched (matching the scripts' resume
109/// behaviour).
110///
111/// Severities are taken in the given order; the canonical [`TaskStatus::to_key`] spelling is used
112/// throughout (no `no-problem`/`no_problem` ambiguity).
113#[allow(clippy::too_many_arguments)] // config-heavy export knobs; a params struct is ceremony here
114pub fn export_html_dataset(
115  connection: &mut PgConnection,
116  corpus: &Corpus,
117  service: &Service,
118  severities: &[TaskStatus],
119  group_by: GroupBy,
120  max_archive_mb: Option<u64>,
121  out_dir: &PathBuf,
122  mut progress: impl FnMut(&str),
123) -> Result<DatasetExportOutcome, String> {
124  fs::create_dir_all(out_dir).map_err(|e| format!("cannot create {}: {e}", out_dir.display()))?;
125
126  progress("Streaming HTML dataset export...");
127  // Stream the matching papers straight into per-archive ZIPs, holding at most ONE archive open at
128  // a time, so the resident footprint is O(page) — not the whole corpus's work-list in a
129  // `BTreeMap` (the old KNOWN_ISSUES E-3 gap). Both modes feed the streamer with same-archive-key
130  // entries contiguous, which is the streamer's one requirement.
131  // MB (binary) → bytes; `saturating_mul` so an absurd value can't overflow the cap.
132  let max_bytes = max_archive_mb.map(|mb| mb.saturating_mul(1024 * 1024));
133  let mut streamer = ArchiveStreamer::new(out_dir, corpus, &service.name, group_by, max_bytes);
134
135  match group_by {
136    // Month: ONE keyset-paginated pass over *all* requested severities, ordered by `entry`. Since
137    // `entry` is `<base>/<yymm>/<paper>/…`, that order makes each `yymm` (the archive key) a
138    // contiguous run — even when one month's papers span several severities.
139    GroupBy::Month => {
140      let raws: Vec<i32> = severities.iter().map(|s| s.raw()).collect();
141      let mut after: Option<String> = None;
142      loop {
143        let page = fetch_entry_page(connection, corpus.id, service.id, &raws, after.as_deref())?;
144        let full = page.len() as i64 == EXPORT_PAGE_SIZE;
145        for entry in &page {
146          streamer.feed(entry, None, &mut progress)?;
147        }
148        after = page.into_iter().next_back();
149        if !full {
150          break;
151        }
152      }
153    },
154    // Severity: one archive per severity, so process a severity at a time — its `entry`-ordered
155    // pages stream into that single archive (the per-severity loop *is* the contiguity).
156    GroupBy::Severity => {
157      for status in severities {
158        let key = status.to_key();
159        let raws = [status.raw()];
160        let mut after: Option<String> = None;
161        loop {
162          let page = fetch_entry_page(connection, corpus.id, service.id, &raws, after.as_deref())?;
163          let full = page.len() as i64 == EXPORT_PAGE_SIZE;
164          for entry in &page {
165            streamer.feed(entry, Some(&key), &mut progress)?;
166          }
167          after = page.into_iter().next_back();
168          if !full {
169            break;
170          }
171        }
172      }
173    },
174  }
175
176  let (archives, total_entries, skipped) = streamer.finish(&mut progress)?;
177
178  let outcome = DatasetExportOutcome {
179    corpus: corpus.name.clone(),
180    service: service.name.clone(),
181    group_by: match group_by {
182      GroupBy::Month => "month",
183      GroupBy::Severity => "severity",
184    }
185    .to_string(),
186    max_archive_mb,
187    severities: severities.iter().map(|s| s.to_key()).collect(),
188    generated_at: chrono::Utc::now().to_rfc3339(),
189    cortex_version: env!("CARGO_PKG_VERSION").to_string(),
190    archives,
191    total_entries,
192    skipped,
193  };
194
195  // Provenance: a manifest recording what this dataset is and how it was built (the owner's
196  // "provenance model" — kept simple: a sidecar JSON, not new DB state).
197  let manifest_path = out_dir.join(format!("{}-manifest.json", corpus.name));
198  let manifest = serde_json::to_string_pretty(&outcome)
199    .map_err(|e| format!("serializing manifest failed: {e}"))?;
200  fs::write(&manifest_path, manifest)
201    .map_err(|e| format!("writing {} failed: {e}", manifest_path.display()))?;
202  progress(&format!("Wrote {}", manifest_path.display()));
203
204  Ok(outcome)
205}
206
207/// One keyset page of matching `entry` paths, ordered by `entry`, strictly after the `after` cursor
208/// (the last entry of the previous page). `entry` is unique within a `(corpus, service)` (the
209/// `UNIQUE(entry, service, corpus)` constraint), so it is a safe, gap-free keyset cursor — O(log n)
210/// per page, no deep-`OFFSET` scan-and-discard. Bounded to [`EXPORT_PAGE_SIZE`] rows.
211fn fetch_entry_page(
212  connection: &mut PgConnection,
213  corpus_id: i32,
214  service_id: i32,
215  status_raws: &[i32],
216  after: Option<&str>,
217) -> Result<Vec<String>, String> {
218  use crate::schema::tasks::dsl as t;
219  let mut query = t::tasks
220    .filter(t::corpus_id.eq(corpus_id))
221    .filter(t::service_id.eq(service_id))
222    .filter(t::status.eq_any(status_raws.to_vec()))
223    .select(t::entry)
224    .order(t::entry.asc())
225    .into_boxed();
226  if let Some(cursor) = after {
227    query = query.filter(t::entry.gt(cursor.to_string()));
228  }
229  query
230    .limit(EXPORT_PAGE_SIZE)
231    .load(connection)
232    .map_err(|e| format!("querying export tasks failed: {e}"))
233}
234
235/// The one archive currently being written by the [`ArchiveStreamer`].
236struct OpenArchive {
237  /// archive key (a `yymm` in month mode, a severity in severity mode)
238  key: String,
239  /// the on-disk file name (`<corpus>-<key>.zip`)
240  name: String,
241  /// the open ZIP writer
242  zip: zip::ZipWriter<File>,
243  /// documents written into it so far
244  written: usize,
245}
246
247/// Streams papers into per-archive-key ZIPs while holding **at most one archive open** at a time,
248/// so the exporter's resident memory is O(one open zip + one paper's HTML) regardless of corpus
249/// size (the fix for the old whole-work-list `BTreeMap` — KNOWN_ISSUES E-3). Callers must feed
250/// entries so that all papers sharing an archive key arrive contiguously; [`export_html_dataset`]
251/// guarantees that in both [`GroupBy`] modes. Resume parity with the scripts: a key whose `.zip`
252/// already exists is skipped without opening a writer (its papers are never read). A paper whose
253/// result archive is missing/unreadable or carries no HTML is skipped (counted), never fatal — one
254/// bad paper must not sink a multi-thousand-paper export (DESIGN_PRINCIPLES: isolate blast radius).
255struct ArchiveStreamer<'a> {
256  out_dir: &'a PathBuf,
257  corpus: &'a Corpus,
258  service_name: &'a str,
259  group_by: GroupBy,
260  /// Optional per-archive byte cap (configurable chunking): when `Some`, a bucket that exceeds it
261  /// rolls into numbered chunks `<corpus>-<key>-NNN.zip`. Measured on **uncompressed** HTML bytes
262  /// — deterministic, so resume re-derives identical boundaries (the published `.zip` is
263  /// smaller).
264  max_bytes: Option<u64>,
265  /// the current month/severity bucket; the chunk counter resets when it changes
266  cur_base_key: Option<String>,
267  /// 1-based chunk index within `cur_base_key` (only > 1 once chunking rolls)
268  cur_chunk: u32,
269  /// uncompressed bytes accumulated into the current chunk (reset on roll / bucket change)
270  cur_bytes: u64,
271  /// the archive currently open for writing (`None` before the first paper / while skipping)
272  open: Option<OpenArchive>,
273  /// the key currently being skipped because its `.zip` already exists (resume)
274  skipping: Option<String>,
275  archives: Vec<DatasetArchive>,
276  total: usize,
277  skipped: usize,
278}
279
280impl<'a> ArchiveStreamer<'a> {
281  fn new(
282    out_dir: &'a PathBuf,
283    corpus: &'a Corpus,
284    service_name: &'a str,
285    group_by: GroupBy,
286    max_bytes: Option<u64>,
287  ) -> Self {
288    ArchiveStreamer {
289      out_dir,
290      corpus,
291      service_name,
292      group_by,
293      max_bytes,
294      cur_base_key: None,
295      cur_chunk: 1,
296      cur_bytes: 0,
297      open: None,
298      skipping: None,
299      archives: Vec::new(),
300      total: 0,
301      skipped: 0,
302    }
303  }
304
305  /// The archive file key for a bucket + chunk index: `<key>-NNN` when chunking, else just `<key>`
306  /// (so non-chunked exports keep the original `<corpus>-<key>.zip` names + resume parity).
307  fn chunk_key(&self, base_key: &str, chunk: u32) -> String {
308    match self.max_bytes {
309      Some(_) => format!("{base_key}-{chunk:03}"),
310      None => base_key.to_string(),
311    }
312  }
313
314  /// Route one task `entry` to its archive: derive `(result_zip, paper, yymm)`, rotate the open
315  /// archive if the key changed, then copy the paper's main HTML in (or count a skip).
316  /// `key_override` forces the archive key (severity mode); month mode passes `None` and uses the
317  /// `yymm`.
318  fn feed(
319    &mut self,
320    entry: &str,
321    key_override: Option<&str>,
322    progress: &mut impl FnMut(&str),
323  ) -> Result<(), String> {
324    // The result archive sits next to the source entry (sandbox-aware, F-6).
325    let result_zip = match result_archive_path(entry, self.service_name, self.corpus.sandbox_id()) {
326      Some(path) => path,
327      None => {
328        self.skipped += 1;
329        return Ok(());
330      },
331    };
332    // Layout: `<base>/<yymm>/<paper>/<service>.zip` ⇒ paper = parent dir name, yymm = its parent.
333    let entry_dir = match result_zip.parent() {
334      Some(dir) => dir,
335      None => {
336        self.skipped += 1;
337        return Ok(());
338      },
339    };
340    let paper = entry_dir.file_name().and_then(|s| s.to_str());
341    let yymm = entry_dir
342      .parent()
343      .and_then(|p| p.file_name())
344      .and_then(|s| s.to_str());
345    let (paper, yymm) = match (paper, yymm) {
346      (Some(p), Some(y)) => (p.to_string(), y.to_string()),
347      _ => {
348        self.skipped += 1;
349        return Ok(());
350      },
351    };
352    let base_key = match key_override {
353      Some(k) => k.to_string(),
354      None => yymm.clone(),
355    };
356    // On a new month/severity bucket, reset the chunk counter and rotate to its first archive.
357    if self.cur_base_key.as_deref() != Some(base_key.as_str()) {
358      self.cur_base_key = Some(base_key.clone());
359      self.cur_chunk = 1;
360      self.cur_bytes = 0;
361      let first = self.chunk_key(&base_key, 1);
362      self.rotate_to(&first, progress)?;
363    }
364    // The internal path is semantic (bucket/paper) — independent of which size-chunk file it lands
365    // in.
366    let internal = match self.group_by {
367      GroupBy::Month => format!("{yymm}/{paper}.html"),
368      GroupBy::Severity => format!("{base_key}/{yymm}/{paper}.html"),
369    };
370    let html = match extract_main_html(&result_zip, &paper) {
371      Some(bytes) => bytes,
372      None => {
373        // Only count an HTML miss when we'd actually be writing (not while resume-skipping a
374        // chunk).
375        if self.open.is_some() {
376          self.skipped += 1;
377        }
378        return Ok(());
379      },
380    };
381    // Size-based chunk roll within the bucket (configurable cap): the paper that would overflow the
382    // current chunk starts the next one (a single paper larger than the cap gets its own chunk).
383    // The accumulator advances for resume-skipped papers too, so resume re-derives identical
384    // boundaries.
385    let size = html.len() as u64;
386    if let Some(cap) = self.max_bytes
387      && self.cur_bytes > 0
388      && self.cur_bytes + size > cap
389    {
390      self.cur_chunk += 1;
391      self.cur_bytes = 0;
392      let next = self.chunk_key(&base_key, self.cur_chunk);
393      self.rotate_to(&next, progress)?;
394    }
395    self.cur_bytes += size;
396    let Some(open) = self.open.as_mut() else {
397      return Ok(());
398    };
399    open
400      .zip
401      .start_file(internal, zip_options())
402      .map_err(|e| format!("zip start_file failed: {e}"))?;
403    open
404      .zip
405      .write_all(&html)
406      .map_err(|e| format!("zip write failed: {e}"))?;
407    open.written += 1;
408    self.total += 1;
409    Ok(())
410  }
411
412  /// Switch the open archive to `key` when the streamed key changes: close the previous archive,
413  /// then either open a fresh writer or (resume) mark the key skipped because its `.zip` exists.
414  fn rotate_to(&mut self, key: &str, progress: &mut impl FnMut(&str)) -> Result<(), String> {
415    let active = self
416      .open
417      .as_ref()
418      .map(|o| o.key.as_str())
419      .or(self.skipping.as_deref());
420    if active == Some(key) {
421      return Ok(());
422    }
423    self.close_open(progress)?;
424    self.skipping = None;
425    let name = format!("{}-{key}.zip", self.corpus.name);
426    let path = self.out_dir.join(&name);
427    if path.exists() {
428      // Resume: an already-written archive is kept as-is (the scripts' behaviour).
429      progress(&format!("  {name} exists — skipping"));
430      self.skipping = Some(key.to_string());
431    } else {
432      // Atomic publish: write to `<name>.partial` and rename to the final name only after the zip
433      // is finalized (`close_open`). A crash mid-write then leaves a `.partial`, **not** a
434      // truncated `.zip` — so the resume (which skips by final-name existence) re-writes it
435      // instead of treating a corrupt half-archive as done. `File::create` truncates any
436      // `.partial` orphaned by a prior crash, so the re-write starts clean.
437      let tmp_path = self.out_dir.join(format!("{name}.partial"));
438      let file = File::create(&tmp_path)
439        .map_err(|e| format!("cannot create {}: {e}", tmp_path.display()))?;
440      self.open = Some(OpenArchive {
441        key: key.to_string(),
442        name,
443        zip: zip::ZipWriter::new(file),
444        written: 0,
445      });
446    }
447    Ok(())
448  }
449
450  /// Finalize the currently-open archive (if any), recording it in the outcome.
451  fn close_open(&mut self, progress: &mut impl FnMut(&str)) -> Result<(), String> {
452    if let Some(open) = self.open.take() {
453      let file = open
454        .zip
455        .finish()
456        .map_err(|e| format!("finalizing {} failed: {e}", open.name))?;
457      drop(file); // close the handle before the rename (atomic publish)
458      // Publish atomically: now that the central directory is written, rename `<name>.partial` →
459      // `<name>` — only a complete archive ever gets the final name (the resume's skip key).
460      let tmp_path = self.out_dir.join(format!("{}.partial", open.name));
461      let final_path = self.out_dir.join(&open.name);
462      fs::rename(&tmp_path, &final_path)
463        .map_err(|e| format!("publishing {} failed: {e}", open.name))?;
464      progress(&format!("  {}: {} document(s)", open.name, open.written));
465      self.archives.push(DatasetArchive {
466        name: open.name,
467        entries: open.written,
468      });
469    }
470    Ok(())
471  }
472
473  /// Close the last open archive and return `(archives, total_documents, skipped)`.
474  fn finish(
475    mut self,
476    progress: &mut impl FnMut(&str),
477  ) -> Result<(Vec<DatasetArchive>, usize, usize), String> {
478    self.close_open(progress)?;
479    Ok((self.archives, self.total, self.skipped))
480  }
481}
482
483/// Pull the main HTML document out of a result archive. Prefers a root-level `<paper>.html`, then
484/// any other root-level `*.html`; returns `None` if the archive is unreadable or HTML-free (the
485/// caller counts it as skipped). HTML-only by design — the arXMLiv datasets bundle the document,
486/// not its assets, exactly as the scripts did (`unzip *.html`).
487fn extract_main_html(result_zip: &PathBuf, paper: &str) -> Option<Vec<u8>> {
488  let file = File::open(result_zip).ok()?;
489  let mut archive = zip::ZipArchive::new(file).ok()?;
490  // Find the best HTML index first (an immutable scan), then read it — `by_index` needs `&mut`.
491  let preferred = format!("{paper}.html");
492  let mut best: Option<usize> = None;
493  for i in 0..archive.len() {
494    let entry = archive.by_index(i).ok()?;
495    let name = entry.name();
496    // Root-level html only (no `/`): the main document, not a nested asset.
497    if name.ends_with(".html") && !name.contains('/') {
498      if name == preferred {
499        best = Some(i);
500        break;
501      }
502      best.get_or_insert(i);
503    }
504  }
505  let index = best?;
506  let mut entry = archive.by_index(index).ok()?;
507  let mut bytes = Vec::new();
508  entry.read_to_end(&mut bytes).ok()?;
509  Some(bytes)
510}
511
512#[cfg(test)]
513mod tests {
514  use super::{GroupBy, extract_main_html};
515  use std::io::Write;
516
517  fn write_zip(path: &std::path::Path, files: &[(&str, &str)]) {
518    let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
519    let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
520    for (name, body) in files {
521      zip.start_file(*name, opts).unwrap();
522      zip.write_all(body.as_bytes()).unwrap();
523    }
524    zip.finish().unwrap();
525  }
526
527  #[test]
528  fn group_by_parses_the_two_modes() {
529    assert_eq!(GroupBy::from_key("month"), Some(GroupBy::Month));
530    assert_eq!(GroupBy::from_key("severity"), Some(GroupBy::Severity));
531    assert!(GroupBy::from_key("yearly").is_none());
532  }
533
534  #[test]
535  fn extract_main_html_prefers_paper_then_root_then_none() {
536    let dir = std::env::temp_dir().join("cortex_export_unit_test");
537    std::fs::create_dir_all(&dir).unwrap();
538
539    // Prefers the `<paper>.html` root entry over other root html and over nested assets.
540    let z1 = dir.join("preferred.zip");
541    write_zip(
542      &z1,
543      &[
544        ("assets/nested.html", "NESTED"),
545        ("other.html", "OTHER"),
546        ("0801.1234.html", "MAIN"),
547        ("cortex.log", "Status:conversion:1"),
548      ],
549    );
550    assert_eq!(
551      extract_main_html(&z1, "0801.1234").as_deref(),
552      Some(b"MAIN".as_slice()),
553      "the paper-named root html wins"
554    );
555
556    // No `<paper>.html`: falls back to any root-level html (not the nested one).
557    let z2 = dir.join("fallback.zip");
558    write_zip(&z2, &[("index.html", "ROOT"), ("sub/deep.html", "DEEP")]);
559    assert_eq!(
560      extract_main_html(&z2, "9999.0001").as_deref(),
561      Some(b"ROOT".as_slice()),
562      "a root-level html is used when the paper-named one is absent"
563    );
564
565    // No root-level html at all ⇒ skipped (None), never a panic.
566    let z3 = dir.join("htmlless.zip");
567    write_zip(&z3, &[("sub/only.html", "DEEP"), ("cortex.log", "x")]);
568    assert!(extract_main_html(&z3, "p").is_none());
569
570    // A non-existent / unreadable archive ⇒ None, not an error.
571    assert!(extract_main_html(&dir.join("nope.zip"), "p").is_none());
572
573    std::fs::remove_dir_all(&dir).ok();
574  }
575}