|
2 | 2 | //! JSON file, so a developer can inspect what Sagittarius last synced |
3 | 3 | //! without needing NATS/KV tooling. Only called when running with |
4 | 4 | //! `environment == DEVELOPMENT`. |
| 5 | +//! |
| 6 | +//! The destination is `config.static_config.flow_path` — the same path |
| 7 | +//! static mode loads from at startup — so a development export can be fed |
| 8 | +//! straight back in as a static-mode fallback. |
5 | 9 |
|
6 | 10 | use std::path::Path; |
7 | 11 |
|
8 | 12 | use tokio::fs; |
9 | | -use tucana::shared::Flows; |
| 13 | +use tucana::shared::{Flows, ValidationFlow}; |
10 | 14 |
|
11 | | -const EXPORT_PATH: &str = "flowExport.json"; |
12 | | -const EXPORT_TMP_PATH: &str = "flowExport.json.tmp"; |
| 15 | +/// Reads the current export at `path`, treating a missing or unparsable |
| 16 | +/// file as an empty set so callers can always fall back to a fresh export. |
| 17 | +async fn read_flows(path: &str) -> Flows { |
| 18 | + match fs::read(path).await { |
| 19 | + Ok(bytes) => match serde_json::from_slice(&bytes) { |
| 20 | + Ok(flows) => flows, |
| 21 | + Err(error) => { |
| 22 | + log::warn!( |
| 23 | + "Failed to parse existing development flow export path={} error={:?}; starting from an empty export", |
| 24 | + path, |
| 25 | + error |
| 26 | + ); |
| 27 | + Flows::default() |
| 28 | + } |
| 29 | + }, |
| 30 | + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Flows::default(), |
| 31 | + Err(error) => { |
| 32 | + log::warn!( |
| 33 | + "Failed to read existing development flow export path={} error={}; starting from an empty export", |
| 34 | + path, |
| 35 | + error |
| 36 | + ); |
| 37 | + Flows::default() |
| 38 | + } |
| 39 | + } |
| 40 | +} |
13 | 41 |
|
14 | | -/// Writes `flows` to `flowExport.json`, replacing any previous export. |
| 42 | +/// Writes `flows` to `path`, replacing any previous export. |
15 | 43 | /// |
16 | | -/// Goes through a temp file plus rename so a reader never observes a |
17 | | -/// partially written export. If the rename fails because the destination |
18 | | -/// already exists, the destination is removed and the rename retried once |
19 | | -/// before giving up. |
20 | | -pub(super) async fn overwrite(flows: Flows) { |
21 | | - let json = match serde_json::to_vec_pretty(&flows) { |
| 44 | +/// Goes through a `path`-adjacent temp file plus rename so a reader never |
| 45 | +/// observes a partially written export. If the rename fails because the |
| 46 | +/// destination already exists, the destination is removed and the rename |
| 47 | +/// retried once before giving up. Returns whether the write succeeded. |
| 48 | +async fn write(path: &str, flows: &Flows) -> bool { |
| 49 | + let json = match serde_json::to_vec_pretty(flows) { |
22 | 50 | Ok(bytes) => bytes, |
23 | 51 | Err(error) => { |
24 | 52 | log::error!( |
25 | 53 | "Failed to serialize development flow export flow_count={} error={:?}", |
26 | 54 | flows.flows.len(), |
27 | 55 | error |
28 | 56 | ); |
29 | | - return; |
| 57 | + return false; |
30 | 58 | } |
31 | 59 | }; |
32 | 60 |
|
33 | | - let final_path = Path::new(EXPORT_PATH); |
34 | | - let tmp_path = Path::new(EXPORT_TMP_PATH); |
| 61 | + let final_path = Path::new(path); |
| 62 | + let tmp_path_buf = format!("{path}.tmp"); |
| 63 | + let tmp_path = Path::new(&tmp_path_buf); |
35 | 64 |
|
36 | 65 | if let Err(error) = fs::write(tmp_path, &json).await { |
37 | 66 | log::error!( |
38 | 67 | "Failed to write development flow export path={} error={}", |
39 | 68 | tmp_path.display(), |
40 | 69 | error |
41 | 70 | ); |
42 | | - return; |
| 71 | + return false; |
43 | 72 | } |
44 | 73 |
|
45 | 74 | if let Err(error) = fs::rename(tmp_path, final_path).await { |
@@ -75,13 +104,61 @@ pub(super) async fn overwrite(flows: Flows) { |
75 | 104 | cleanup_error |
76 | 105 | ); |
77 | 106 | } |
78 | | - return; |
| 107 | + return false; |
79 | 108 | } |
80 | 109 | } |
81 | 110 |
|
82 | | - log::info!( |
83 | | - "Exported {} flows to {}", |
84 | | - flows.flows.len(), |
85 | | - final_path.display() |
86 | | - ); |
| 111 | + true |
| 112 | +} |
| 113 | + |
| 114 | +/// Overwrites `path` with `flows` wholesale. |
| 115 | +pub(super) async fn overwrite(path: &str, flows: Flows) { |
| 116 | + let flow_count = flows.flows.len(); |
| 117 | + if write(path, &flows).await { |
| 118 | + log::info!("Exported {} flows to {}", flow_count, path); |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +/// Adds or replaces a single flow in the export at `path`, read-modify-write. |
| 123 | +pub(super) async fn upsert_flow(path: &str, flow: ValidationFlow) { |
| 124 | + let mut flows = read_flows(path).await; |
| 125 | + let flow_id = flow.flow_id; |
| 126 | + |
| 127 | + match flows.flows.iter_mut().find(|f| f.flow_id == flow_id) { |
| 128 | + Some(existing) => *existing = flow, |
| 129 | + None => flows.flows.push(flow), |
| 130 | + } |
| 131 | + |
| 132 | + if write(path, &flows).await { |
| 133 | + log::info!( |
| 134 | + "Upserted flow_id={} into development flow export path={}", |
| 135 | + flow_id, |
| 136 | + path |
| 137 | + ); |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +/// Removes a single flow from the export at `path` by id, read-modify-write. |
| 142 | +/// Logs a warning (without failing) if no matching flow was found. |
| 143 | +pub(super) async fn remove_flow(path: &str, flow_id: i64) { |
| 144 | + let mut flows = read_flows(path).await; |
| 145 | + let original_count = flows.flows.len(); |
| 146 | + flows.flows.retain(|f| f.flow_id != flow_id); |
| 147 | + |
| 148 | + if flows.flows.len() == original_count { |
| 149 | + log::warn!( |
| 150 | + "Development flow export had no flow matching flow_id={} to remove path={}", |
| 151 | + flow_id, |
| 152 | + path |
| 153 | + ); |
| 154 | + return; |
| 155 | + } |
| 156 | + |
| 157 | + if write(path, &flows).await { |
| 158 | + log::info!( |
| 159 | + "Removed flow_id={} from development flow export path={}", |
| 160 | + flow_id, |
| 161 | + path |
| 162 | + ); |
| 163 | + } |
87 | 164 | } |
0 commit comments