Skip to main content

spreadsheet_kit/recalc/
formualizer_backend.rs

1use super::RecalcResult;
2use crate::recalc::RecalcBackend;
3use crate::utils::column_number_to_name;
4use anyhow::{Result, anyhow};
5use async_trait::async_trait;
6use formualizer::common::PackedSheetCell;
7use formualizer::eval::engine::ingest::EngineLoadStream;
8use formualizer::eval::engine::{Engine, EvalConfig, FormulaParsePolicy};
9use formualizer::workbook::workbook::WBResolver;
10use formualizer::workbook::{
11    FormulaCacheUpdate, LiteralValue, SpreadsheetReader, SpreadsheetWriter, UmyaAdapter,
12};
13use std::collections::HashSet;
14use std::path::Path;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::thread;
18use std::time::{Duration, Instant};
19
20pub struct FormualizerBackend;
21
22#[async_trait]
23impl RecalcBackend for FormualizerBackend {
24    async fn recalculate(
25        &self,
26        fork_work_path: &Path,
27        timeout_ms: Option<u64>,
28    ) -> Result<RecalcResult> {
29        let path = fork_work_path.to_path_buf();
30        // Use a dedicated thread with a 32 MiB stack instead of
31        // tokio::task::spawn_blocking (which uses 2 MiB by default).
32        // Deep formula chains (e.g. 30k cascading rows) can exceed 2 MiB
33        // in debug builds.
34        let (tx, rx) = tokio::sync::oneshot::channel();
35        std::thread::Builder::new()
36            .name("formualizer-recalc".into())
37            .stack_size(32 * 1024 * 1024)
38            .spawn(move || {
39                let _ = tx.send(recalc_sync(&path, timeout_ms));
40            })
41            .map_err(|e| anyhow!("failed to spawn recalc thread: {e}"))?;
42        rx.await.map_err(|_| anyhow!("recalc thread panicked"))?
43    }
44
45    fn is_available(&self) -> bool {
46        true
47    }
48
49    fn name(&self) -> &'static str {
50        "formualizer"
51    }
52}
53
54type FormualizerEngine = Engine<WBResolver>;
55
56fn recalc_sync(path: &Path, timeout_ms: Option<u64>) -> Result<RecalcResult> {
57    let start = Instant::now();
58
59    let open_start = Instant::now();
60    let mut adapter = UmyaAdapter::open_path(path)
61        .map_err(|e| anyhow!("failed to open workbook adapter {:?}: {e}", path))?;
62    let open_ms = open_start.elapsed().as_millis() as u64;
63
64    let formula_cells = adapter.formula_cells();
65    let formula_cells_len = formula_cells.len();
66
67    // Fast recalc path by default for agentic/stateless workflows:
68    // - defer graph building during ingest (dramatically reduces load time)
69    // - coerce malformed formulas to errors so one bad sheet doesn't abort the full run
70    let eval_config = EvalConfig {
71        defer_graph_building: true,
72        formula_parse_policy: FormulaParsePolicy::CoerceToError,
73        ..Default::default()
74    };
75
76    let mut engine = FormualizerEngine::new(WBResolver::default(), eval_config);
77
78    let stream_start = Instant::now();
79    adapter
80        .stream_into_engine(&mut engine)
81        .map_err(|e| anyhow!("failed to ingest workbook into formualizer engine: {e}"))?;
82    let stream_ms = stream_start.elapsed().as_millis() as u64;
83
84    let eval_start = Instant::now();
85    let (cells_evaluated, cycle_errors, changed_cells) =
86        evaluate_with_optional_timeout(&mut engine, timeout_ms)
87            .map_err(|e| anyhow!("formualizer evaluate_all failed: {e}"))?;
88    let evaluate_ms = eval_start.elapsed().as_millis() as u64;
89
90    let mut eval_errors = Vec::new();
91    if cycle_errors > 0 {
92        eval_errors.push(format!(
93            "Detected {} circular reference cycle(s). Cells in cycles are reported as #CIRC! by this backend; workbooks built with Excel's iterative calculation (common in financial models with interest/cash-sweep circularity) need a backend that iterates. If LibreOffice is installed, retry with SPREADSHEET_MCP_RECALC_BACKEND=libreoffice. Do not try to 'fix' intentional circular references.",
94            cycle_errors
95        ));
96    }
97
98    let build_updates_start = Instant::now();
99    let date_system = engine.config.date_system;
100    let changed_filter = changed_cells.as_ref();
101    let mut cache_updates = Vec::with_capacity(formula_cells_len);
102    for (sheet_name, row, col) in formula_cells {
103        let value = engine
104            .get_cell_value(&sheet_name, row, col)
105            .unwrap_or(LiteralValue::Empty);
106
107        if let LiteralValue::Error(err) = &value
108            && eval_errors.len() < 200
109        {
110            let addr = format!("{}{}", column_number_to_name(col), row);
111            eval_errors.push(format!("{}!{}: {}", sheet_name, addr, err));
112        }
113
114        let should_write = if let Some(changed) = changed_filter {
115            match engine
116                .sheet_id(&sheet_name)
117                .and_then(|sid| PackedSheetCell::try_from_excel_1based(sid, row, col))
118            {
119                Some(packed) => changed.contains(&packed),
120                None => true,
121            }
122        } else {
123            true
124        };
125
126        if should_write {
127            cache_updates.push(FormulaCacheUpdate {
128                sheet: sheet_name,
129                row,
130                col,
131                value,
132            });
133        }
134    }
135    let build_updates_ms = build_updates_start.elapsed().as_millis() as u64;
136
137    let updates_len = cache_updates.len();
138
139    let mut write_formula_caches_batch_ms = 0u64;
140    let mut save_as_path_ms = 0u64;
141
142    if !cache_updates.is_empty() {
143        let write_start = Instant::now();
144        adapter
145            .write_formula_caches_batch(&cache_updates, date_system)
146            .map_err(|e| anyhow!("failed to write formula caches in batch: {e}"))?;
147        write_formula_caches_batch_ms = write_start.elapsed().as_millis() as u64;
148
149        let save_start = Instant::now();
150        adapter
151            .save_as_path(path)
152            .map_err(|e| anyhow!("failed to save recalculated workbook {:?}: {e}", path))?;
153        save_as_path_ms = save_start.elapsed().as_millis() as u64;
154    }
155
156    let total_ms = start.elapsed().as_millis() as u64;
157
158    tracing::trace!(
159        target: "asp::recalc::timing",
160        open_ms,
161        stream_into_engine_ms = stream_ms,
162        evaluate_ms,
163        build_updates_ms,
164        write_formula_caches_batch_ms,
165        save_as_path_ms,
166        formula_cells_len,
167        updates_len,
168        total_ms,
169        "formualizer recalc timing"
170    );
171
172    Ok(RecalcResult {
173        duration_ms: total_ms,
174        was_warm: true,
175        backend_name: "formualizer",
176        cells_evaluated: Some(cells_evaluated),
177        eval_errors: if eval_errors.is_empty() {
178            None
179        } else {
180            Some(eval_errors)
181        },
182    })
183}
184
185fn evaluate_with_optional_timeout(
186    engine: &mut FormualizerEngine,
187    timeout_ms: Option<u64>,
188) -> Result<(u64, u64, Option<HashSet<PackedSheetCell>>)> {
189    let Some(timeout_ms) = timeout_ms else {
190        let (eval, delta) = engine.evaluate_all_with_delta()?;
191        let changed = delta.changed_cells.into_iter().collect::<HashSet<_>>();
192        return Ok((
193            eval.computed_vertices as u64,
194            eval.cycle_errors as u64,
195            Some(changed),
196        ));
197    };
198
199    let cancel_flag = Arc::new(AtomicBool::new(false));
200    let done_flag = Arc::new(AtomicBool::new(false));
201    let cancel_for_thread = cancel_flag.clone();
202    let done_for_thread = done_flag.clone();
203
204    let handle = thread::spawn(move || {
205        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
206        // Relaxed is sufficient: flag is monotonic false->true, no data synchronized.
207        while !done_for_thread.load(Ordering::Relaxed) {
208            if Instant::now() >= deadline {
209                cancel_for_thread.store(true, Ordering::Relaxed);
210                break;
211            }
212            thread::sleep(Duration::from_millis(5));
213        }
214    });
215
216    let result = engine.evaluate_all_cancellable(cancel_flag);
217    done_flag.store(true, Ordering::Relaxed);
218    let _ = handle.join();
219
220    let eval = result?;
221    Ok((
222        eval.computed_vertices as u64,
223        eval.cycle_errors as u64,
224        None,
225    ))
226}