Add statistics tracking for syncback operations

- Introduced a new `SyncbackStats` module to track and report issues during syncback, including duplicate names, unknown classes, and properties.
- Updated the `syncback_loop` and related functions to utilize the stats tracker, logging summaries of issues encountered.
- Enhanced property filtering and instance syncing to record statistics on unknown classes and properties.
- Modified tests to ensure consistent output by normalizing temporary paths in snapshots.
- Added `regex` dependency for path normalization in tests.
This commit is contained in:
Joe 2026-02-01 08:29:00 -06:00
parent 58399bb863
commit 8fe908740f
14 changed files with 541 additions and 84 deletions

View file

@ -4,6 +4,7 @@ mod hash;
mod property_filter;
mod ref_properties;
mod snapshot;
mod stats;
use anyhow::Context;
use indexmap::IndexMap;
@ -35,6 +36,7 @@ pub use property_filter::{
filter_properties, filter_properties_preallocated, should_property_serialize,
};
pub use snapshot::{inst_path, SyncbackData, SyncbackSnapshot};
pub use stats::SyncbackStats;
/// The name of an enviroment variable to use to override the behavior of
/// syncback on model files.
@ -72,10 +74,27 @@ static GIT_IGNORE_GLOB: OnceLock<Glob> = OnceLock::new();
pub fn syncback_loop(
vfs: &Vfs,
old_tree: &mut RojoTree,
mut new_tree: WeakDom,
new_tree: WeakDom,
project: &Project,
incremental: bool,
) -> anyhow::Result<FsSnapshot> {
syncback_loop_with_stats(vfs, old_tree, new_tree, project, incremental, None)
}
/// Runs the syncback loop with an optional external stats tracker.
/// If no stats are provided, an internal one is created and logged at the end.
pub fn syncback_loop_with_stats(
vfs: &Vfs,
old_tree: &mut RojoTree,
mut new_tree: WeakDom,
project: &Project,
incremental: bool,
external_stats: Option<&SyncbackStats>,
) -> anyhow::Result<FsSnapshot> {
// Create internal stats if not provided externally
let internal_stats = SyncbackStats::new();
let stats = external_stats.unwrap_or(&internal_stats);
let ignore_patterns = project
.syncback_rules
.as_ref()
@ -223,6 +242,7 @@ pub fn syncback_loop(
new_tree: &new_tree,
project,
incremental,
stats,
};
// Always start with old reference for the Project middleware.
@ -304,10 +324,8 @@ pub fn syncback_loop(
extension_for_middleware(new_middleware)
));
let new_snapshot = snapshot.with_new_path(path, snapshot.new, snapshot.old);
log::warn!(
"Could not syncback {inst_path} as a Directory because: {err}.\n\
It will instead be synced back as a {new_middleware:?}."
);
// Record the fallback in stats instead of warning directly
stats.record_rbxm_fallback(&inst_path, &err.to_string());
let new_syncback_result = new_middleware
.syncback(&new_snapshot)
.with_context(|| format!("Failed to syncback {inst_path}"));
@ -428,6 +446,7 @@ pub fn syncback_loop(
// Skip any path that is inside another path we're removing,
// since remove_dir_all will handle the contents.
let paths_vec: Vec<_> = paths_to_remove.iter().collect();
let normalized_project_path = normalize_path(project_path);
for old_path in &paths_vec {
// Check if this path is inside another path we're removing
let is_nested = paths_vec
@ -438,15 +457,26 @@ pub fn syncback_loop(
continue;
}
log::debug!("Removing orphaned path: {}", old_path.display());
// Convert absolute path to relative path for FsSnapshot.
// FsSnapshot expects relative paths and joins them with base_path in write_to_vfs.
let relative_path = old_path
.strip_prefix(&normalized_project_path)
.unwrap_or(old_path);
log::debug!("Removing orphaned path: {}", relative_path.display());
if old_path.is_dir() {
fs_snapshot.remove_dir(old_path);
fs_snapshot.remove_dir(relative_path);
} else {
fs_snapshot.remove_file(old_path);
fs_snapshot.remove_file(relative_path);
}
}
}
// Log stats summary if using internal stats (not provided externally)
if external_stats.is_none() {
stats.log_summary();
}
Ok(fs_snapshot)
}

View file

@ -3,6 +3,8 @@ use rbx_reflection::{PropertyKind, PropertySerialization, Scriptability};
use crate::{variant_eq::variant_eq, Project};
use super::SyncbackStats;
/// Returns a map of properties from `inst` that are both allowed under the
/// user-provided settings, are not their default value, and serialize.
pub fn filter_properties<'inst>(
@ -21,6 +23,18 @@ pub fn filter_properties_preallocated<'inst>(
project: &Project,
inst: &'inst Instance,
allocation: &mut Vec<(Ustr, &'inst Variant)>,
) {
filter_properties_with_stats(project, inst, allocation, None);
}
/// Fills `allocation` with a list of properties from `inst` that are
/// user-provided settings, are not their default value, and serialize.
/// Optionally tracks unknown classes and properties via the stats tracker.
pub fn filter_properties_with_stats<'inst>(
project: &Project,
inst: &'inst Instance,
allocation: &mut Vec<(Ustr, &'inst Variant)>,
stats: Option<&SyncbackStats>,
) {
let sync_unscriptable = project
.syncback_rules
@ -28,17 +42,22 @@ pub fn filter_properties_preallocated<'inst>(
.and_then(|s| s.sync_unscriptable)
.unwrap_or(true);
let class_data = rbx_reflection_database::get()
.unwrap()
.classes
.get(inst.class.as_str());
let database = rbx_reflection_database::get().unwrap();
let class_data = database.classes.get(inst.class.as_str());
// Track unknown class if not found
if class_data.is_none() {
if let Some(stats) = stats {
stats.record_unknown_class(&inst.class);
}
}
let predicate = |prop_name: &Ustr, prop_value: &Variant| {
// We don't want to serialize Ref or UniqueId properties in JSON files
if matches!(prop_value, Variant::Ref(_) | Variant::UniqueId(_)) {
return true;
}
if !should_property_serialize(&inst.class, prop_name) {
if !should_property_serialize_with_stats(&inst.class, prop_name, stats) {
return true;
}
if !sync_unscriptable {
@ -80,13 +99,30 @@ pub fn filter_properties_preallocated<'inst>(
/// Checks if a property should serialize based on the reflection database.
/// Returns false for properties with DoesNotSerialize serialization, true otherwise.
pub fn should_property_serialize(class_name: &str, prop_name: &str) -> bool {
should_property_serialize_with_stats(class_name, prop_name, None)
}
/// Checks if a property should serialize based on the reflection database.
/// Returns false for properties with DoesNotSerialize serialization, true otherwise.
/// Optionally tracks unknown properties via the stats tracker.
pub fn should_property_serialize_with_stats(
class_name: &str,
prop_name: &str,
stats: Option<&SyncbackStats>,
) -> bool {
let database = rbx_reflection_database::get().unwrap();
let mut current_class_name = class_name;
loop {
let class_data = match database.classes.get(current_class_name) {
Some(data) => data,
None => return true,
None => {
// Unknown class - track it if stats provided
if let Some(stats) = stats {
stats.record_unknown_class(current_class_name);
}
return true;
}
};
if let Some(data) = class_data.properties.get(prop_name) {
log::trace!("found {class_name}.{prop_name} on {current_class_name}");
@ -94,7 +130,7 @@ pub fn should_property_serialize(class_name: &str, prop_name: &str) -> bool {
// It's not really clear if this can ever happen but I want to
// support it just in case!
PropertyKind::Alias { alias_for } => {
should_property_serialize(current_class_name, alias_for)
should_property_serialize_with_stats(current_class_name, alias_for, stats)
}
// Migrations and aliases are happily handled for us by parsers
// so we don't really need to handle them.
@ -109,5 +145,11 @@ pub fn should_property_serialize(class_name: &str, prop_name: &str) -> bool {
break;
}
}
// Property not found in class hierarchy - track it if stats provided
if let Some(stats) = stats {
stats.record_unknown_property(class_name, prop_name);
}
true
}

View file

@ -13,7 +13,9 @@ use rbx_dom_weak::{
Instance, Ustr, UstrMap, WeakDom,
};
use super::{get_best_middleware, name_for_inst, property_filter::filter_properties};
use super::{
get_best_middleware, name_for_inst, property_filter::filter_properties, SyncbackStats,
};
#[derive(Clone, Copy)]
pub struct SyncbackData<'sync> {
@ -24,6 +26,8 @@ pub struct SyncbackData<'sync> {
/// When true, preserves existing file structure and middleware formats.
/// When false (clean mode), all child instances are treated as new.
pub(super) incremental: bool,
/// Statistics tracker for syncback issues.
pub(super) stats: &'sync SyncbackStats,
}
pub struct SyncbackSnapshot<'sync> {
@ -327,6 +331,12 @@ impl<'sync> SyncbackSnapshot<'sync> {
.map(|rules| rules.warn_duplicate_names())
.unwrap_or(false)
}
/// Returns a reference to the syncback statistics tracker.
#[inline]
pub fn stats(&self) -> &'sync SyncbackStats {
self.data.stats
}
}
pub fn filter_out_property(inst: &Instance, prop_name: &str) -> bool {

334
src/syncback/stats.rs Normal file
View file

@ -0,0 +1,334 @@
//! Statistics tracking for syncback operations.
//!
//! This module provides a way to track and report various issues that occur
//! during syncback, including:
//! - Instances with duplicate names (indistinguishable paths)
//! - Instances that fell back to rbxm/rbxmx format
//! - Unknown classes not in the reflection database
//! - Unknown properties not in the reflection database
use std::collections::HashSet;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
/// Statistics collected during a syncback operation.
///
/// This struct is designed to be used in a single-threaded context during
/// the main syncback loop, but uses interior mutability for flexibility.
#[derive(Default)]
pub struct SyncbackStats {
/// Count of instances skipped due to duplicate/indistinguishable names.
duplicate_name_count: AtomicUsize,
/// Count of instances that fell back to rbxm/rbxmx format.
rbxm_fallback_count: AtomicUsize,
/// Count of instances with unknown classes.
unknown_class_count: AtomicUsize,
/// Count of properties with unknown definitions.
unknown_property_count: AtomicUsize,
/// Set of unknown class names encountered (for reporting).
unknown_classes: Mutex<HashSet<String>>,
/// Set of unknown property names encountered (class.property format).
unknown_properties: Mutex<HashSet<String>>,
}
impl SyncbackStats {
/// Creates a new empty stats tracker.
pub fn new() -> Self {
Self::default()
}
/// Records that an instance was skipped due to having a duplicate name.
///
/// When debug logging is enabled, logs the individual instance path.
pub fn record_duplicate_name(&self, inst_path: &str, name: &str) {
self.duplicate_name_count.fetch_add(1, Ordering::Relaxed);
// Only log individual instances at debug level to avoid spam
if log::log_enabled!(log::Level::Debug) {
log::debug!(
"Skipping instance due to duplicate name: '{}' at '{}'",
name,
inst_path
);
}
}
/// Records that multiple instances were skipped due to duplicate names at a path.
///
/// This is more efficient when multiple duplicates are detected at once.
pub fn record_duplicate_names_batch(
&self,
inst_path: &str,
duplicate_names: &[&str],
total_skipped: usize,
) {
self.duplicate_name_count
.fetch_add(total_skipped, Ordering::Relaxed);
// Only log at debug level
if log::log_enabled!(log::Level::Debug) {
log::debug!(
"Skipping {} instance(s) with duplicate names at '{}': {:?}",
total_skipped,
inst_path,
duplicate_names
);
}
}
/// Records that an instance fell back to rbxm/rbxmx format.
pub fn record_rbxm_fallback(&self, inst_path: &str, reason: &str) {
self.rbxm_fallback_count.fetch_add(1, Ordering::Relaxed);
// Only log individual fallbacks at debug level
if log::log_enabled!(log::Level::Debug) {
log::debug!(
"Instance '{}' fell back to binary model format: {}",
inst_path,
reason
);
}
}
/// Records that an unknown class was encountered.
pub fn record_unknown_class(&self, class_name: &str) {
self.unknown_class_count.fetch_add(1, Ordering::Relaxed);
if let Ok(mut classes) = self.unknown_classes.lock() {
if classes.insert(class_name.to_string()) {
// Only log when we see a new unknown class
if log::log_enabled!(log::Level::Debug) {
log::debug!(
"Encountered unknown class not in reflection database: '{}'",
class_name
);
}
}
}
}
/// Records that an unknown property was encountered.
pub fn record_unknown_property(&self, class_name: &str, property_name: &str) {
self.unknown_property_count.fetch_add(1, Ordering::Relaxed);
let key = format!("{}.{}", class_name, property_name);
if let Ok(mut properties) = self.unknown_properties.lock() {
if properties.insert(key.clone()) {
// Only log when we see a new unknown property
if log::log_enabled!(log::Level::Debug) {
log::debug!(
"Encountered unknown property not in reflection database: '{}'",
key
);
}
}
}
}
/// Returns the count of instances skipped due to duplicate names.
pub fn duplicate_name_count(&self) -> usize {
self.duplicate_name_count.load(Ordering::Relaxed)
}
/// Returns the count of instances that fell back to rbxm format.
pub fn rbxm_fallback_count(&self) -> usize {
self.rbxm_fallback_count.load(Ordering::Relaxed)
}
/// Returns the count of unknown classes encountered.
pub fn unknown_class_count(&self) -> usize {
self.unknown_class_count.load(Ordering::Relaxed)
}
/// Returns the count of unknown properties encountered.
pub fn unknown_property_count(&self) -> usize {
self.unknown_property_count.load(Ordering::Relaxed)
}
/// Returns the unique unknown class names.
pub fn unknown_classes(&self) -> Vec<String> {
self.unknown_classes
.lock()
.map(|g| g.iter().cloned().collect())
.unwrap_or_default()
}
/// Returns the unique unknown properties (as "Class.Property").
pub fn unknown_properties(&self) -> Vec<String> {
self.unknown_properties
.lock()
.map(|g| g.iter().cloned().collect())
.unwrap_or_default()
}
/// Returns true if any issues were recorded.
pub fn has_issues(&self) -> bool {
self.duplicate_name_count() > 0
|| self.rbxm_fallback_count() > 0
|| self.unknown_class_count() > 0
|| self.unknown_property_count() > 0
}
/// Returns true if there are unknown classes or properties that should be
/// reported for reflection database updates.
pub fn has_unknown_types(&self) -> bool {
self.unknown_class_count() > 0 || self.unknown_property_count() > 0
}
/// Logs a summary of all recorded issues as warnings.
///
/// This should be called at the end of a syncback operation.
pub fn log_summary(&self) {
let duplicate_count = self.duplicate_name_count();
let rbxm_count = self.rbxm_fallback_count();
let unknown_class_count = self.unknown_class_count();
let unknown_prop_count = self.unknown_property_count();
if !self.has_issues() {
return;
}
log::warn!("Syncback completed with issues:");
if duplicate_count > 0 {
log::warn!(
" - {} instance(s) could not be synced due to duplicate/indistinguishable names",
duplicate_count
);
}
if rbxm_count > 0 {
log::warn!(
" - {} instance(s) fell back to binary model format (rbxm/rbxmx)",
rbxm_count
);
}
if unknown_class_count > 0 {
let classes = self.unknown_classes();
log::warn!(
" - {} instance(s) have unknown classes not in reflection database",
unknown_class_count
);
if log::log_enabled!(log::Level::Info) && !classes.is_empty() {
log::info!(" Unknown classes: {:?}", classes);
}
}
if unknown_prop_count > 0 {
let properties = self.unknown_properties();
log::warn!(
" - {} property reference(s) to unknown properties not in reflection database",
unknown_prop_count
);
if log::log_enabled!(log::Level::Info) && !properties.is_empty() {
// Only show first 20 to avoid spam
let display: Vec<_> = properties.iter().take(20).collect();
let remaining = properties.len().saturating_sub(20);
if remaining > 0 {
log::info!(
" Unknown properties (showing 20 of {}): {:?}",
properties.len(),
display
);
} else {
log::info!(" Unknown properties: {:?}", display);
}
}
}
// Helpful hint about debug logging
if duplicate_count > 0 || rbxm_count > 0 {
log::info!(
" Enable debug logging (RUST_LOG=debug) to see individual instance details"
);
}
}
/// Merges stats from another SyncbackStats instance into this one.
pub fn merge(&self, other: &SyncbackStats) {
self.duplicate_name_count.fetch_add(
other.duplicate_name_count.load(Ordering::Relaxed),
Ordering::Relaxed,
);
self.rbxm_fallback_count.fetch_add(
other.rbxm_fallback_count.load(Ordering::Relaxed),
Ordering::Relaxed,
);
self.unknown_class_count.fetch_add(
other.unknown_class_count.load(Ordering::Relaxed),
Ordering::Relaxed,
);
self.unknown_property_count.fetch_add(
other.unknown_property_count.load(Ordering::Relaxed),
Ordering::Relaxed,
);
if let (Ok(mut self_classes), Ok(other_classes)) =
(self.unknown_classes.lock(), other.unknown_classes.lock())
{
self_classes.extend(other_classes.iter().cloned());
}
if let (Ok(mut self_props), Ok(other_props)) = (
self.unknown_properties.lock(),
other.unknown_properties.lock(),
) {
self_props.extend(other_props.iter().cloned());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stats_tracking() {
let stats = SyncbackStats::new();
assert!(!stats.has_issues());
stats.record_duplicate_name("Root/Folder", "Script");
stats.record_duplicate_name("Root/Folder", "Script");
assert_eq!(stats.duplicate_name_count(), 2);
stats.record_rbxm_fallback("Root/Model", "cannot represent as directory");
assert_eq!(stats.rbxm_fallback_count(), 1);
stats.record_unknown_class("MyCustomClass");
stats.record_unknown_class("MyCustomClass"); // duplicate
assert_eq!(stats.unknown_class_count(), 2);
assert_eq!(stats.unknown_classes().len(), 1); // unique
stats.record_unknown_property("Part", "CustomProp");
assert_eq!(stats.unknown_property_count(), 1);
assert!(stats.has_issues());
assert!(stats.has_unknown_types());
}
#[test]
fn test_batch_recording() {
let stats = SyncbackStats::new();
stats.record_duplicate_names_batch("Root/Folder", &["Script", "Model"], 4);
assert_eq!(stats.duplicate_name_count(), 4);
}
#[test]
fn test_merge() {
let stats1 = SyncbackStats::new();
let stats2 = SyncbackStats::new();
stats1.record_duplicate_name("path1", "name1");
stats2.record_duplicate_name("path2", "name2");
stats2.record_unknown_class("Class1");
stats1.merge(&stats2);
assert_eq!(stats1.duplicate_name_count(), 2);
assert_eq!(stats1.unknown_classes().len(), 1);
}
}