Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
821952e
test: drop stale imports of removed rule and diagnostics modules
kvlonge Oct 15, 2026
cc83e8d
refactor: host lint, lineage, scope, discovery and analysis-session r…
kvlonge Oct 15, 2026
2314ba5
refactor: host compile, discovery, rule, planner and dbt value record…
kvlonge Oct 15, 2026
3f42069
chore: record staying executor, adapter and API shapes and move migra…
kvlonge Oct 15, 2026
461ee42
refactor: declare compile input protocols in the extension stub and r…
kvlonge Oct 15, 2026
529093a
chore: reconcile boundary after data-model leftovers
kvlonge Oct 15, 2026
1a3ffd2
chore: drop stale stub imports after data-model integration
kvlonge Oct 15, 2026
5967cab
refactor: add native lifecycle-event export policy and priority queue
kvlonge Oct 15, 2026
cf548ab
refactor: host lifecycle-event export natively
kvlonge Oct 15, 2026
b1fe83f
fix: stop command-output delivery before interpreter finalization
kvlonge Oct 15, 2026
7ecee6c
chore: drop duplicated stub imports after event export integration
kvlonge Oct 15, 2026
7befb23
fix: join background SQL test planning before interpreter exit
kvlonge Oct 15, 2026
3d1c7fe
test: import native SqlTestSelection in planning exit test
kvlonge Oct 15, 2026
6c48768
refactor: plan janitor cleanup natively around adapter callbacks
kvlonge Oct 15, 2026
7e415bb
refactor: build imported project Python discovery records natively ar…
kvlonge Oct 15, 2026
d8b335e
refactor: parse Dagster invocation arguments, logs and payloads natively
kvlonge Oct 15, 2026
29474bc
refactor: build integration loader records natively around runtime lo…
kvlonge Oct 15, 2026
babb0c2
fix: keep provider settings unloaded during plain compile discovery
kvlonge Oct 15, 2026
9d5191e
fix: run command-output sinks on one thread per event exporter dispat…
kvlonge Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub(crate) mod name_parts;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! Fixed-width ASCII segments of reserved relation names.

/// The rest of `name` after an ASCII case-insensitive `prefix`.
pub(crate) fn strip_prefix_folded<'a>(name: &'a str, prefix: &str) -> Option<&'a str> {
let head: &str = name.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then(|| name.get(prefix.len()..))
.flatten()
}

/// `width` leading ASCII digits as a number, and the rest of `text`.
pub(crate) fn leading_number(text: &str, width: usize) -> Option<(u32, &str)> {
let digits: &str = text.get(..width)?;
if !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let value: u32 = digits
.bytes()
.fold(0, |total, byte| total * 10 + u32::from(byte - b'0'));
Some((value, text.get(width..)?))
}

/// The rest of `text` after one ASCII case-insensitive `marker` character.
pub(crate) fn after_marker(text: &str, marker: char) -> Option<&str> {
let mut characters = text.chars();
characters
.next()
.filter(|character| character.eq_ignore_ascii_case(&marker))
.map(|_| characters.as_str())
}
69 changes: 69 additions & 0 deletions crates/sqlbuild-analysis/src/janitor_planning/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Janitor planning names, prefixes and skip reasons.

/// SQLBuild state tables the janitor never acts on.
pub const BUILT_IN_EXCLUDE_PATTERNS: [&str; 9] = [
"_sqlbuild_fingerprints",
"_sqlbuild_microbatches",
"_sqlbuild_source_freshness",
"_sqlbuild_node_results",
"_sqlbuild_audit_results",
"_sqlbuild_janitor_events",
"_sqlbuild_migrations",
"_sqlbuild_column_migrations",
"_sqlbuild_old_name_views",
];
/// Prefix of every strict janitor archive name.
pub const ARCHIVE_NAME_PREFIX: &str = "_SQB_ARCHIVE__";
/// Separator between archive name segments.
pub const ARCHIVE_NAME_SEPARATOR: &str = "__";
/// Folded prefix reserving a name for janitor archive handling.
pub const ARCHIVE_LOOKALIKE_PREFIX: &str = "_sqb_archive";
/// Label used in archive name fitting errors.
pub const ARCHIVE_ARTIFACT_LABEL: &str = "Janitor archive";
/// Reason for names that may require quoting.
pub const UNQUOTED_ADDRESSING_REASON: &str = "relation name is not a plain lowercase identifier and may require quoting; janitor does not act on it";
/// Reason for names that collide case-insensitively.
pub const CASE_COLLISION_REASON: &str =
"relation name collides case-insensitively with another relation; janitor does not act on it";
/// Reason for archive lookalikes outside the strict grammar.
pub const MALFORMED_ARCHIVE_REASON: &str =
"name resembles a janitor archive but does not match the strict archive grammar";
/// Reason for query-diff artifacts left to fingerprint expiry cleanup.
pub const QUERY_DIFF_ARTIFACT_REASON: &str = "query-diff artifacts use fingerprint expiry cleanup";
/// Reason for reserved query-diff names without ownership evidence.
pub const UNTRACKED_QUERY_DIFF_REASON: &str =
"reserved query-diff name lacks matching ownership evidence";
/// Default reason for protected relations.
pub const PROTECTED_REASON: &str = "relation is protected from cleanup";
/// Reason for relations without a SQLBuild fingerprint.
pub const UNTRACKED_REASON: &str = "relation is not tracked by SQLBuild";
/// Reason when the adapter has no relation age metadata.
pub const AGE_UNSUPPORTED_REASON: &str = "adapter does not expose relation age metadata";
/// Reason when a relation's age is unknown.
pub const AGE_UNAVAILABLE_REASON: &str = "relation age is unavailable";
/// Reason protecting compatibility views at old model names.
pub const OLD_NAME_VIEW_REASON: &str = "compatibility view at an old model name";
/// Parts of a fully qualified migration origin.
pub const QUALIFIED_ORIGIN_PARTS: usize = 3;
/// Default janitor relation retention in days.
pub const DEFAULT_RETENTION_DAYS: i64 = 14;
/// Prefix of reserved query-diff artifact names.
pub const QUERY_DIFF_ARTIFACT_PREFIX: &str = "__sqlbuild_query_diff_";
/// Sides a query-diff artifact may hold.
pub const QUERY_DIFF_SIDES: [&str; 2] = ["left", "right"];
/// Hex digits ending a query-diff run id.
pub const ARTIFACT_RUN_HEX_DIGITS: usize = 12;
/// Digits of a year in a compact UTC timestamp.
pub const YEAR_DIGITS: usize = 4;
/// Digits of a month or day in a compact UTC timestamp.
pub const DATE_DIGITS: usize = 2;
/// Digits of an hour, minute or second in a compact UTC timestamp.
pub const TIME_DIGITS: usize = 2;
/// Largest month number.
pub const LAST_MONTH: u32 = 12;
/// Largest hour.
pub const LAST_HOUR: u32 = 23;
/// Largest minute.
pub const LAST_MINUTE: u32 = 59;
/// Largest second; leap seconds are rejected as Python's `datetime` rejects them.
pub const LAST_SECOND: u32 = 59;
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Fitted, case-folded janitor archive names.

use crate::identifier_fitting::main::fit_artifact_logical_name::fit_artifact_logical_name;
use crate::janitor_planning::constants::{
ARCHIVE_ARTIFACT_LABEL, ARCHIVE_NAME_PREFIX, ARCHIVE_NAME_SEPARATOR,
};
use crate::janitor_planning::models::ArchiveTimestamp;

/// The archive name keeping prefix, timestamp and kind intact; `Err` carries the fitting error.
pub fn build_archive_name(
(original_name, archived_at): (&str, ArchiveTimestamp),
identifier_limit: i64,
kind: Option<&str>,
) -> Result<String, String> {
let kind_segment: String = kind.map_or_else(String::new, |kind| {
format!("{kind}{ARCHIVE_NAME_SEPARATOR}")
});
let fixed_prefix: String = format!(
"{ARCHIVE_NAME_PREFIX}{:04}{:02}{:02}T{:02}{:02}{:02}Z{ARCHIVE_NAME_SEPARATOR}{kind_segment}",
archived_at.year,
archived_at.month,
archived_at.day,
archived_at.hour,
archived_at.minute,
archived_at.second,
);
let logical_part: String = fit_artifact_logical_name(
(original_name, &fixed_prefix),
identifier_limit,
ARCHIVE_ARTIFACT_LABEL,
)?;
Ok(format!("{fixed_prefix}{logical_part}").to_lowercase())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//! Folded names shared by several relations in one listing.

use std::collections::{HashMap, HashSet};

/// Case-folded names that occur more than once in `names`.
pub fn case_colliding_names<'a>(names: impl IntoIterator<Item = &'a str>) -> HashSet<String> {
let mut counts: HashMap<String, usize> = HashMap::new();
for name in names {
*counts.entry(name.to_lowercase()).or_insert(0) += 1;
}
counts
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|(name, _)| name)
.collect()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//! Relations whose folded name is shared in their listing.

use std::collections::HashSet;

use crate::janitor_planning::constants::CASE_COLLISION_REASON;

/// Why a relation must not be dropped because its folded name collides, if it does.
#[must_use]
pub fn case_collision_reason(
name: &str,
colliding_names: &HashSet<String>,
) -> Option<&'static str> {
colliding_names
.contains(&name.to_lowercase())
.then_some(CASE_COLLISION_REASON)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! Names reserved for janitor archive handling.

use crate::janitor_planning::constants::ARCHIVE_LOOKALIKE_PREFIX;

/// Whether a relation name starts, ignoring case, with the archive prefix.
#[must_use]
pub fn is_archive_lookalike_name(name: &str) -> bool {
name.to_lowercase().starts_with(ARCHIVE_LOOKALIKE_PREFIX)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! Janitor exclude patterns matched against relation and qualified names.

use sqlbuild_core::text::main::glob_match::glob_match;

/// The first pattern matching the name or the qualified display name, ignoring case.
#[must_use]
pub fn matching_exclude_pattern<'a>(
(name, display_name): (&str, &str),
patterns: &'a [String],
) -> Option<&'a str> {
let name: String = name.to_lowercase();
let display_name: String = display_name.to_lowercase();
patterns
.iter()
.find(|pattern| {
let folded: String = pattern.to_lowercase();
glob_match(&name, &folded) || glob_match(&display_name, &folded)
})
.map(String::as_str)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Relations a `migrate_from` value may name.

use crate::janitor_planning::constants::QUALIFIED_ORIGIN_PARTS;
use crate::janitor_planning::models::OriginKey;

/// Origin keys for `raw`: a bare name in each sorted schema, else the one qualified relation.
#[must_use]
pub fn migration_origin_keys(
raw: &str,
destination_database: Option<&str>,
sorted_schemas: &[(Option<String>, Option<String>)],
) -> Vec<OriginKey> {
let parts: Vec<String> = raw
.split('.')
.map(|part| {
part.trim()
.trim_matches(|c| c == '"' || c == '`')
.to_owned()
})
.collect();
if let [name] = parts.as_slice() {
return sorted_schemas
.iter()
.map(|(database, schema)| OriginKey {
database: database.clone(),
schema: schema.clone(),
name: name.clone(),
})
.collect();
}
let count: usize = parts.len();
vec![OriginKey {
database: if count == QUALIFIED_ORIGIN_PARTS {
Some(parts[0].clone())
} else {
destination_database.map(str::to_owned)
},
schema: Some(parts[count - 2].clone()),
name: parts[count - 1].clone(),
}]
}
11 changes: 11 additions & 0 deletions crates/sqlbuild-analysis/src/janitor_planning/main/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
pub mod build_archive_name;
pub mod case_colliding_names;
pub mod case_collision_reason;
pub mod is_archive_lookalike_name;
pub mod matching_exclude_pattern;
pub mod migration_origin_keys;
pub mod old_name_request_matches;
pub mod parse_archive_name;
pub mod query_diff_artifact_parts;
pub mod resolve_retention_days;
pub mod unaddressable_relation_reason;
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! `--drop-old-name-view` requests matched against recorded old names.

/// Whether the dotted request names the trailing parts of the recorded old name, ignoring case.
#[must_use]
pub fn old_name_request_matches(
request: &str,
(database, schema, name): (Option<&str>, Option<&str>, &str),
) -> bool {
let parts: Vec<String> = request
.split('.')
.map(|part| part.trim().trim_matches('"').to_lowercase())
.collect();
let spelled: Vec<String> = [database, schema, Some(name)]
.into_iter()
.flatten()
.map(str::to_lowercase)
.collect();
spelled.len() >= parts.len() && spelled[spelled.len() - parts.len()..] == parts[..]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! The strict janitor archive name grammar.

use crate::janitor_planning::_helpers::name_parts::{
after_marker, leading_number, strip_prefix_folded,
};
use crate::janitor_planning::constants::{
ARCHIVE_NAME_PREFIX, ARCHIVE_NAME_SEPARATOR, DATE_DIGITS, LAST_HOUR, LAST_MINUTE, LAST_MONTH,
LAST_SECOND, TIME_DIGITS, YEAR_DIGITS,
};
use crate::janitor_planning::models::{ArchiveTimestamp, ParsedArchiveName};

/// The archive timestamp and logical name, or `None` for anything outside the strict grammar.
#[must_use]
pub fn parse_archive_name(name: &str) -> Option<ParsedArchiveName> {
let rest: &str = strip_prefix_folded(name, ARCHIVE_NAME_PREFIX)?;
let (year, rest) = leading_number(rest, YEAR_DIGITS)?;
let (month, rest) = leading_number(rest, DATE_DIGITS)?;
let (day, rest) = leading_number(rest, DATE_DIGITS)?;
let rest: &str = after_marker(rest, 'T')?;
let (hour, rest) = leading_number(rest, TIME_DIGITS)?;
let (minute, rest) = leading_number(rest, TIME_DIGITS)?;
let (second, rest) = leading_number(rest, TIME_DIGITS)?;
let rest: &str = after_marker(rest, 'Z')?;
let logical_name: &str = rest.strip_prefix(ARCHIVE_NAME_SEPARATOR)?;
if logical_name.is_empty() || logical_name.contains('\n') {
return None;
}
let archived_at = ArchiveTimestamp {
year,
month,
day,
hour,
minute,
second,
};
valid(archived_at).then(|| ParsedArchiveName {
archived_at,
logical_name: logical_name.to_owned(),
})
}

fn valid(value: ArchiveTimestamp) -> bool {
value.year >= 1
&& (1..=LAST_MONTH).contains(&value.month)
&& value.day >= 1
&& value.day <= days_in_month(value.year, value.month)
&& value.hour <= LAST_HOUR
&& value.minute <= LAST_MINUTE
&& value.second <= LAST_SECOND
}

fn days_in_month(year: u32, month: u32) -> u32 {
let leap: bool =
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
match month {
2 if leap => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//! The strict reserved query-diff artifact name format.

use crate::janitor_planning::_helpers::name_parts::{
after_marker, leading_number, strip_prefix_folded,
};
use crate::janitor_planning::constants::{
ARTIFACT_RUN_HEX_DIGITS, DATE_DIGITS, QUERY_DIFF_ARTIFACT_PREFIX, QUERY_DIFF_SIDES,
TIME_DIGITS, YEAR_DIGITS,
};

/// The artifact's run id and folded side, or `None` when the name is not a query-diff artifact.
#[must_use]
pub fn query_diff_artifact_parts(name: &str) -> Option<(String, String)> {
let run_and_side: &str = strip_prefix_folded(name, QUERY_DIFF_ARTIFACT_PREFIX)?;
let (_, rest) = leading_number(run_and_side, YEAR_DIGITS + 2 * DATE_DIGITS)?;
let rest: &str = after_marker(rest, 'T')?;
let (_, rest) = leading_number(rest, 3 * TIME_DIGITS)?;
let rest: &str = after_marker(rest, 'Z')?;
let rest: &str = rest.strip_prefix('_')?;
let hex: &str = rest.get(..ARTIFACT_RUN_HEX_DIGITS)?;
if !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let side: &str = rest.get(ARTIFACT_RUN_HEX_DIGITS..)?.strip_prefix('_')?;
let folded: String = side.to_ascii_lowercase();
if !QUERY_DIFF_SIDES.contains(&folded.as_str()) {
return None;
}
let run_id: &str = run_and_side.get(..run_and_side.len() - side.len() - 1)?;
Some((run_id.to_owned(), folded))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! Effective janitor relation retention.

use crate::janitor_planning::constants::DEFAULT_RETENTION_DAYS;

/// The CLI override, else the configured days, else the default.
#[must_use]
pub fn resolve_retention_days(override_days: Option<i64>, configured: Option<i64>) -> i64 {
override_days
.or(configured)
.unwrap_or(DEFAULT_RETENTION_DAYS)
}
Loading
Loading