Skip to content

Commit 26963e4

Browse files
AmmarAbouZorDmitryAstafyev
authored andcommitted
Build CLI: Improve last build states manager
* Use serde for loading & saving the file instead of the manual parsing * Additional features on the last run are involved in the persisted states * Use one file for states instead of two for development and production by including the production state in the file itself * Rename the module and structs for the build records * Move additional features to thier own module * Add new build state file name to gitignore
1 parent 2924f32 commit 26963e4

11 files changed

+394
-306
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,4 @@ scripts/tools/file_checklists/*.*
7676
# CLI build tool files #
7777
########################
7878
.build_chksum_*
79+
.build_last_state

cli/src/build_state_records.rs

+336
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
//! Module to manage the track changes between each run by comparing the files' states
2+
//! and the included additional features between the current and the previous build run.
3+
//! File comparison is done by calculating the checksum of the files on each target then comparing
4+
//! it with persisted checksum from the last run.
5+
//!
6+
//! This module manages saving the loading the state records as well.
7+
8+
use std::{
9+
collections::{btree_map, BTreeMap, BTreeSet},
10+
fs::File,
11+
io::{BufReader, BufWriter},
12+
ops::Deref,
13+
path::PathBuf,
14+
sync::{Mutex, OnceLock},
15+
};
16+
17+
use anyhow::{anyhow, Context};
18+
use console::style;
19+
use dir_checksum::calc_combined_checksum;
20+
use serde::{Deserialize, Serialize};
21+
22+
use crate::{
23+
job_type::JobType, jobs_runner::additional_features::AdditionalFeatures, location::get_root,
24+
target::Target, JobsState,
25+
};
26+
27+
/// Deprecated filenames that were used previously.
28+
const DEPRECATED_FILE_NAMES: [&str; 2] = [".build_chksum_dev", ".build_chksum_prod"];
29+
30+
/// Name of the file used to save the state of the last build run.
31+
const PERSIST_FILE_NAME: &str = ".build_last_state";
32+
33+
#[derive(Debug, Serialize, Deserialize)]
34+
/// Manages and compares the file states for the targets between current and previous builds.
35+
/// It calculates the checksums of the files for each targets and saves them to a file after
36+
/// each build, and for the next build it'll calculate the checksum again and compare it with
37+
/// the saved one.
38+
/// It also manages loading and clearing the saved checksum records as well.
39+
pub struct BuildStateRecords {
40+
items: Mutex<BuildStateItems>,
41+
}
42+
43+
#[derive(Debug, Deserialize, Serialize)]
44+
struct BuildStateItems {
45+
production: bool,
46+
states: BTreeMap<Target, TargetBuildState>,
47+
#[serde(skip)]
48+
involved_targets: BTreeSet<Target>,
49+
}
50+
51+
impl BuildStateItems {
52+
fn new(production: bool) -> Self {
53+
Self {
54+
production,
55+
states: BTreeMap::default(),
56+
involved_targets: BTreeSet::default(),
57+
}
58+
}
59+
}
60+
61+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62+
struct TargetBuildState {
63+
hash: String,
64+
#[serde(skip_serializing_if = "Option::is_none")]
65+
#[serde(default)]
66+
features: Option<BTreeSet<AdditionalFeatures>>,
67+
}
68+
69+
impl TargetBuildState {
70+
pub fn new(hash: String) -> Self {
71+
Self {
72+
hash,
73+
features: None,
74+
}
75+
}
76+
77+
pub fn add_feature(&mut self, feature: AdditionalFeatures) {
78+
let features = self.features.get_or_insert_with(BTreeSet::new);
79+
features.insert(feature);
80+
}
81+
}
82+
83+
/// Represents the comparison's result between the saved Checksum and the calculate one for the
84+
/// build target
85+
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
86+
pub enum ChecksumCompareResult {
87+
Same,
88+
Changed,
89+
}
90+
91+
impl BuildStateRecords {
92+
/// Update checksum records for involved jobs depending on the job type.
93+
/// It will calculate new checksums if build tasks were involved.
94+
pub fn update_and_save(job_type: JobType) -> anyhow::Result<()> {
95+
// Records should be involved when build is called at some point of the job or by clean.
96+
let (records_involved, prod) = match &job_type {
97+
// Linting build targets for TS targets and their dependencies
98+
JobType::Lint => (true, false),
99+
JobType::Build { production }
100+
| JobType::Run { production }
101+
| JobType::Test { production } => (true, *production),
102+
// With clean we need to remove the whole records file.
103+
JobType::Clean => return Self::remove_records_file(),
104+
JobType::Install { production } | JobType::AfterBuild { production } => {
105+
(false, *production)
106+
}
107+
};
108+
109+
let records = Self::get(prod)?;
110+
111+
if records_involved {
112+
records.calculate_involved_states()?;
113+
}
114+
115+
records
116+
.persist_build_state()
117+
.context("Error while saving the updated build states")?;
118+
119+
Ok(())
120+
}
121+
122+
/// Returns a reference to build states' records manager singleton
123+
pub fn get(production: bool) -> anyhow::Result<&'static BuildStateRecords> {
124+
static CHECKSUM_RECORDS: OnceLock<anyhow::Result<BuildStateRecords>> = OnceLock::new();
125+
126+
CHECKSUM_RECORDS
127+
.get_or_init(|| BuildStateRecords::load(production))
128+
.as_ref()
129+
.map_err(|err| anyhow!("{err}"))
130+
}
131+
132+
/// Loads the persisted records from states file if exist
133+
/// The states from previous build will be ignored in case production state between
134+
/// them is different.
135+
fn load(production: bool) -> anyhow::Result<Self> {
136+
let file_path = Self::persist_file_path();
137+
138+
let items = if file_path.exists() {
139+
let file = File::open(&file_path).with_context(|| {
140+
format!(
141+
"Error while opening last build state records file. Path: {}",
142+
file_path.display()
143+
)
144+
})?;
145+
let reader = BufReader::new(file);
146+
let mut items: BuildStateItems = serde_json::from_reader(reader)?;
147+
148+
// Production and development use the same artifacts which will lead to false
149+
// positives when the artifacts are modified via another build but the checksum of
150+
// source files still the same.
151+
// To solve this problem we will reset the states of the opposite build production
152+
// type when build is involved in the current process
153+
if items.production != production {
154+
items = BuildStateItems::new(production);
155+
}
156+
items
157+
} else {
158+
BuildStateItems::new(production)
159+
};
160+
161+
Ok(Self {
162+
items: Mutex::new(items),
163+
})
164+
}
165+
166+
/// Gets the path of the file where the build states are saved
167+
fn persist_file_path() -> PathBuf {
168+
get_root().join(PERSIST_FILE_NAME)
169+
}
170+
171+
/// Remove deprecated files which were used to persist hashes only.
172+
/// TODO: Remove this function when enough time has passed.
173+
fn cleanup_depr_files() {
174+
let root = get_root();
175+
DEPRECATED_FILE_NAMES
176+
.iter()
177+
.map(|name| root.join(name))
178+
.filter(|path| path.exists())
179+
.for_each(|path| {
180+
if let Err(err) = std::fs::remove_file(&path) {
181+
let msg = format!(
182+
"Error while removing deprecated checksum files.\n\
183+
File paht: {}\nError: {err:#?}",
184+
path.display()
185+
);
186+
187+
eprintln!("{}", style(msg).yellow());
188+
}
189+
})
190+
}
191+
192+
/// Removes the records file if exists
193+
pub fn remove_records_file() -> anyhow::Result<()> {
194+
Self::cleanup_depr_files();
195+
196+
let file_path = Self::persist_file_path();
197+
if file_path.exists() {
198+
std::fs::remove_file(&file_path).with_context(|| {
199+
format!(
200+
"Error while removing the file {} to reset build state records",
201+
file_path.display()
202+
)
203+
})?;
204+
}
205+
206+
Ok(())
207+
}
208+
209+
/// Marks the job is involved in the record tracker
210+
pub fn register_job(&self, target: Target) -> anyhow::Result<()> {
211+
let mut items = self
212+
.items
213+
.lock()
214+
.map_err(|err| anyhow!("Error while acquiring items jobs mutex: Error {err}"))?;
215+
items.involved_targets.insert(target);
216+
Ok(())
217+
}
218+
219+
/// Compares the current build state for the given target with the previous run by calculating
220+
/// and comparing the files checksum and the applied additional features to the given target.
221+
///
222+
/// # Panics
223+
///
224+
/// This method panics if the provided target isn't registered
225+
pub fn compare_checksum(&self, target: Target) -> anyhow::Result<ChecksumCompareResult> {
226+
let items = self
227+
.items
228+
.lock()
229+
.map_err(|err| anyhow!("Error while acquiring items jobs mutex: Error {err}"))?;
230+
231+
assert!(items.involved_targets.contains(&target));
232+
let saved_state = match items.states.get(&target) {
233+
Some(state) => state,
234+
// If there is no existing checksum to compare with, then the checksums state has
235+
// changed.
236+
None => return Ok(ChecksumCompareResult::Changed),
237+
};
238+
239+
let current_hash = Self::calc_hash_for_target(target)?;
240+
241+
// Check for the hash only at first then check the additional features.
242+
let comparison = if current_hash == saved_state.hash {
243+
let mut target_features = None;
244+
JobsState::get()
245+
.additional_features()
246+
.iter()
247+
.filter(|f| f.apply_to_target() == target)
248+
.for_each(|feature| {
249+
target_features
250+
.get_or_insert_with(BTreeSet::new)
251+
.insert(*feature);
252+
});
253+
254+
if target_features == saved_state.features {
255+
ChecksumCompareResult::Same
256+
} else {
257+
ChecksumCompareResult::Changed
258+
}
259+
} else {
260+
ChecksumCompareResult::Changed
261+
};
262+
263+
Ok(comparison)
264+
}
265+
266+
/// Calculates the checksums for the source files of the given target
267+
/// returning it converted to a string.
268+
fn calc_hash_for_target(target: Target) -> anyhow::Result<String> {
269+
let path = target.cwd();
270+
calc_combined_checksum(path)
271+
.map(|digst| digst.to_string())
272+
.with_context(|| {
273+
format!("Error while calculating the current hash for target: {target}",)
274+
})
275+
}
276+
277+
/// Remove the target from the states records
278+
pub fn remove_state_if_exist(&self, target: Target) -> anyhow::Result<()> {
279+
let mut items = self
280+
.items
281+
.lock()
282+
.map_err(|err| anyhow!("Error while acquiring items jobs mutex: Error {err}"))?;
283+
284+
items.involved_targets.insert(target);
285+
286+
items.states.remove(&target);
287+
288+
Ok(())
289+
}
290+
291+
fn calculate_involved_states(&self) -> anyhow::Result<()> {
292+
let mut items = self
293+
.items
294+
.lock()
295+
.map_err(|err| anyhow!("Error while acquiring items jobs mutex: Error {err}"))?;
296+
297+
let additional_features = JobsState::get().additional_features();
298+
299+
for target in items.involved_targets.clone() {
300+
let hash = Self::calc_hash_for_target(target)?;
301+
let mut target_state = TargetBuildState::new(hash);
302+
additional_features
303+
.iter()
304+
.filter(|f| f.apply_to_target() == target)
305+
.for_each(|f| {
306+
target_state.add_feature(*f);
307+
});
308+
309+
match items.states.entry(target) {
310+
btree_map::Entry::Occupied(mut o) => *o.get_mut() = target_state,
311+
btree_map::Entry::Vacant(e) => _ = e.insert(target_state),
312+
};
313+
}
314+
315+
Ok(())
316+
}
317+
318+
fn persist_build_state(&self) -> anyhow::Result<()> {
319+
let file_path = Self::persist_file_path();
320+
321+
let file = File::create(&file_path).with_context(|| {
322+
format!(
323+
"Creating build states file failed. Path: {}",
324+
file_path.display()
325+
)
326+
})?;
327+
let writer = BufWriter::new(file);
328+
let items = self
329+
.items
330+
.lock()
331+
.map_err(|err| anyhow!("Error while acquiring items mutex. Error: {err:?}"))?;
332+
serde_json::to_writer_pretty(writer, items.deref())
333+
.context("Error while serializing build state to persist them")?;
334+
Ok(())
335+
}
336+
}

0 commit comments

Comments
 (0)