spreadsheet_kit/recalc/
formualizer_backend.rs1use 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 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 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 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}