diff --git a/Cli/Program.cs b/Cli/Program.cs index 9f34c29..aa58cf8 100644 --- a/Cli/Program.cs +++ b/Cli/Program.cs @@ -170,6 +170,13 @@ private static int RunUpgrade(CliOptions options) case SchemaAction.UpgradeInPlace: DatabaseMaintenance.UpgradeInPlace(options.UpgradeDbPath); Console.WriteLine($"Upgraded database schema from v{before.Major}.{before.Minor} to {current}."); + var applied = DatabaseSchemaInfo.ChangesSince(before.Major, before.Minor); + if (applied.Count > 0) + { + Console.WriteLine("Applied (views/indexes re-created):"); + foreach (var change in applied) + Console.WriteLine($" • {change}"); + } return 0; } diff --git a/Cli/SchemaGate.cs b/Cli/SchemaGate.cs index a731fe5..8441841 100644 --- a/Cli/SchemaGate.cs +++ b/Cli/SchemaGate.cs @@ -72,6 +72,8 @@ private static void HandleUpgradeInPlace(SchemaStatus status, string current, st { DatabaseMaintenance.UpgradeInPlace(status.DatabasePath); Console.Error.WriteLine($"Upgraded database schema to {current}."); + foreach (var change in DatabaseSchemaInfo.ChangesSince(status.Major, status.Minor)) + Console.Error.WriteLine($" • {change}"); } else { diff --git a/Core/ExportDestination/DuckDbExportDestination.cs b/Core/ExportDestination/DuckDbExportDestination.cs index 9bd1823..193e2e7 100644 --- a/Core/ExportDestination/DuckDbExportDestination.cs +++ b/Core/ExportDestination/DuckDbExportDestination.cs @@ -667,6 +667,34 @@ FROM native_objects b WHERE b.native_type_name = 'AssetBundle' GROUP BY b.native_object_index, b.name, b.size_bytes, b.resident_size_bytes, b.is_destroyed; +-- One row per (AssetBundle, loaded native object) pair: the exploded, per-asset companion to +-- v_assetbundle_utilization (which is the per-bundle aggregate). The refs CTE is the SAME filter the +-- utilization view uses, so the bundle's own native self-reference (to_index = from_index) and its +-- managed wrapper(s) (excluded by to_kind = 'native_object' + native_connection) are left out — every +-- row is a genuine OTHER asset the bundle keeps loaded, with no magic numbers. +CREATE OR REPLACE VIEW v_assetbundle_loaded_assets AS +WITH refs AS ( + SELECT DISTINCT c.from_index AS bundle_index, c.to_index AS asset_index + FROM connections c + JOIN native_objects b ON b.native_object_index = c.from_index AND b.native_type_name = 'AssetBundle' + WHERE c.from_kind = 'native_object' AND c.to_kind = 'native_object' + AND c.connection_type = 'native_connection' AND c.to_index <> c.from_index +) +SELECT + b.native_object_index AS bundle_index, + b.name AS bundle_name, + b.size_bytes AS bundle_size_bytes, + b.resident_size_bytes AS bundle_resident_bytes, + o.native_object_index AS asset_index, + o.name AS asset_name, + o.native_type_name AS asset_type_name, + o.size_bytes AS asset_size_bytes, + o.resident_size_bytes AS asset_resident_bytes, + o.is_destroyed AS asset_is_destroyed +FROM refs r +JOIN native_objects b ON b.native_object_index = r.bundle_index +JOIN native_objects o ON o.native_object_index = r.asset_index; + CREATE OR REPLACE MACRO region_allocations(region_name) AS TABLE SELECT * FROM v_allocation_enriched WHERE system_region_name = region_name; diff --git a/Core/ExportDestination/SqliteWriter.cs b/Core/ExportDestination/SqliteWriter.cs index f9db90e..380f012 100644 --- a/Core/ExportDestination/SqliteWriter.cs +++ b/Core/ExportDestination/SqliteWriter.cs @@ -808,6 +808,7 @@ private static long QueryCount(SqliteConnection connection, string sql) #endregion private const string SchemaTablesScript = """ +DROP VIEW IF EXISTS v_assetbundle_loaded_assets; DROP VIEW IF EXISTS v_assetbundle_utilization; DROP VIEW IF EXISTS v_connection_edges; DROP VIEW IF EXISTS v_region_owner_breakdown; @@ -936,6 +937,7 @@ resident_available INTEGER NOT NULL // Drops the analysis views so CreateViewsScript (which uses CREATE VIEW, not CREATE OR REPLACE, // for SQLite) is re-runnable by the in-place upgrade path. Order does not matter with IF EXISTS. private const string DropViewsScript = """ +DROP VIEW IF EXISTS v_assetbundle_loaded_assets; DROP VIEW IF EXISTS v_assetbundle_utilization; DROP VIEW IF EXISTS v_connection_edges; DROP VIEW IF EXISTS v_region_owner_breakdown; @@ -1042,6 +1044,34 @@ FROM native_objects b LEFT JOIN native_objects o ON o.native_object_index = r.ref_index WHERE b.native_type_name = 'AssetBundle' GROUP BY b.native_object_index, b.name, b.size_bytes, b.resident_size_bytes, b.is_destroyed; + +-- One row per (AssetBundle, loaded native object) pair: the exploded, per-asset companion to +-- v_assetbundle_utilization (which is the per-bundle aggregate). The refs CTE is the SAME filter the +-- utilization view uses, so the bundle's own native self-reference (to_index = from_index) and its +-- managed wrapper(s) (excluded by to_kind = 'native_object' + native_connection) are left out — every +-- row is a genuine OTHER asset the bundle keeps loaded, with no magic numbers. +CREATE VIEW v_assetbundle_loaded_assets AS +WITH refs AS ( + SELECT DISTINCT c.from_index AS bundle_index, c.to_index AS asset_index + FROM connections c + JOIN native_objects b ON b.native_object_index = c.from_index AND b.native_type_name = 'AssetBundle' + WHERE c.from_kind = 'native_object' AND c.to_kind = 'native_object' + AND c.connection_type = 'native_connection' AND c.to_index <> c.from_index +) +SELECT + b.native_object_index AS bundle_index, + b.name AS bundle_name, + b.size_bytes AS bundle_size_bytes, + b.resident_size_bytes AS bundle_resident_bytes, + o.native_object_index AS asset_index, + o.name AS asset_name, + o.native_type_name AS asset_type_name, + o.size_bytes AS asset_size_bytes, + o.resident_size_bytes AS asset_resident_bytes, + o.is_destroyed AS asset_is_destroyed +FROM refs r +JOIN native_objects b ON b.native_object_index = r.bundle_index +JOIN native_objects o ON o.native_object_index = r.asset_index; """; } diff --git a/Core/Models/DatabaseSchemaInfo.cs b/Core/Models/DatabaseSchemaInfo.cs index e0194ae..c965344 100644 --- a/Core/Models/DatabaseSchemaInfo.cs +++ b/Core/Models/DatabaseSchemaInfo.cs @@ -60,7 +60,7 @@ public static class DatabaseSchemaInfo public const int SchemaMajor = 1; /// Current minor schema version (views/indexes). A lower minor can be upgraded in place. - public const int SchemaMinor = 2; + public const int SchemaMinor = 3; /// Name used in advisories to refer to the CLI tool. public const string ToolName = "MemorySnapshotDataTools"; @@ -71,6 +71,42 @@ public static class DatabaseSchemaInfo ?? typeof(DatabaseSchemaInfo).Assembly.GetName().Version?.ToString() ?? "unknown"; + /// + /// Version-by-version summary of what each schema version introduced, mirroring the version table + /// in docs/database-schema.md. Ordered oldest → newest. Used by + /// so the upgrade command can tell the user exactly what an in-place upgrade applied. Add a + /// row here whenever you bump or . + /// + public static readonly IReadOnlyList<(int Major, int Minor, string Summary)> Changelog = new[] + { + (1, 0, "Initial versioned schema: schema_meta, page_size, region analysis views/macros."), + (1, 1, "Added v_connection_edges and v_assetbundle_utilization views."), + (1, 2, "Reformulated v_connection_edges joins so filtered queries hash-join (much faster)."), + (1, 3, "Added v_assetbundle_loaded_assets view (the assets each AssetBundle keeps loaded)."), + }; + + /// + /// The summaries for every schema version newer than the supplied + /// (, ) up to and including this build's version, + /// each prefixed with its version (e.g. "v1.3: …"). Describes what an in-place upgrade from + /// the stored version applied; empty when nothing newer exists. + /// + /// The database's stored major version. + /// The database's stored minor version. + public static IReadOnlyList ChangesSince(int major, int minor) + { + var changes = new List(); + foreach (var (m, n, summary) in Changelog) + { + var newerThanStored = m > major || (m == major && n > minor); + var atMostCurrent = m < SchemaMajor || (m == SchemaMajor && n <= SchemaMinor); + if (newerThanStored && atMostCurrent) + changes.Add($"v{m}.{n}: {summary}"); + } + + return changes; + } + /// Classifies a database's stored (major, minor) version against this build. /// Value from schema_meta.schema_version_major, or 0 if the table is absent. /// Value from schema_meta.schema_version_minor, or 0 if absent. diff --git a/Tests/DatabaseSchemaInfoTests.cs b/Tests/DatabaseSchemaInfoTests.cs index dcc9dd6..00c34a7 100644 --- a/Tests/DatabaseSchemaInfoTests.cs +++ b/Tests/DatabaseSchemaInfoTests.cs @@ -53,4 +53,28 @@ public void BuildReExportCommand_FormatsCommand(bool sqlite, string expected) { Assert.Equal(expected, DatabaseSchemaInfo.BuildReExportCommand("/snaps/a.snap", "/dbs/a.duckdb", sqlite)); } + + [Fact] + public void Changelog_CoversCurrentVersion() + { + // Guard: bumping SchemaMajor/SchemaMinor without adding a Changelog row would leave the + // upgrade command unable to describe the new version. + Assert.Contains(DatabaseSchemaInfo.Changelog, + e => e.Major == DatabaseSchemaInfo.SchemaMajor && e.Minor == DatabaseSchemaInfo.SchemaMinor); + } + + [Fact] + public void ChangesSince_CurrentVersion_IsEmpty() + { + Assert.Empty(DatabaseSchemaInfo.ChangesSince(DatabaseSchemaInfo.SchemaMajor, DatabaseSchemaInfo.SchemaMinor)); + } + + [Fact] + public void ChangesSince_OneMinorBehind_DescribesCurrentVersion() + { + // A database one minor behind should be told exactly which (single) version the upgrade applied. + var changes = DatabaseSchemaInfo.ChangesSince(DatabaseSchemaInfo.SchemaMajor, DatabaseSchemaInfo.SchemaMinor - 1); + var change = Assert.Single(changes); + Assert.StartsWith($"v{DatabaseSchemaInfo.SchemaMajor}.{DatabaseSchemaInfo.SchemaMinor}:", change); + } } diff --git a/docs/database-schema.md b/docs/database-schema.md index 868d5fd..24b536e 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -40,6 +40,7 @@ Reset minor to 0 whenever you bump major. | 1.0 | First versioned schema: `schema_meta`, `snapshot_info.page_size`, region analysis views/macros. | | 1.1 | Added `v_connection_edges` and `v_assetbundle_utilization` views (minor — upgradeable in place). | | 1.2 | Reformulated `v_connection_edges` joins (kind check folded into the join key) so DuckDB hash-joins instead of nested-loop — `SELECT … WHERE from_type=…` drops from minutes to sub-second (minor). | +| 1.3 | Added `v_assetbundle_loaded_assets` view — one row per (AssetBundle, loaded native object) it references (minor — upgradeable in place). | **What the CLI does.** Before a read command (`report`, `summary`, `validate`), `DatabaseSchemaInfo.Evaluate(major, minor)` classifies the database and the CLI acts: @@ -58,8 +59,16 @@ MemorySnapshotDataTools upgrade ``` This re-applies indexes and views (`DatabaseMaintenance.UpgradeInPlace`) and bumps the stored minor -version. It refuses major-version gaps and tells you to re-export instead. Non-interactive sessions -(stdin redirected) never auto-modify a database — they only print the advisory and command. +version, then lists which schema versions were applied (from `DatabaseSchemaInfo.ChangesSince`, the +same per-version summaries as the [version table](#schema-version) below). It refuses major-version +gaps and tells you to re-export instead. Non-interactive sessions (stdin redirected) never auto-modify +a database — they only print the advisory and command. + +```text +Upgraded database schema from v1.2 to v1.3. +Applied (views/indexes re-created): + • v1.3: Added v_assetbundle_loaded_assets view (the assets each AssetBundle keeps loaded). +``` The stored version is also **displayed in output**: `summary` prints a `Schema` field, the HTML `report` shows a *Schema Version* row in Snapshot Info, and `multi-report` shows it per database @@ -288,6 +297,49 @@ WHERE references_loaded_assets ORDER BY referenced_object_count DESC; > flattened bundle→contained-object edges, so this is comprehensive for bundles; it is not transitive > retained size, and the same shared asset may be counted under more than one bundle. +### `v_assetbundle_loaded_assets` (view) +The **exploded, per-asset companion** to `v_assetbundle_utilization`: one row per *(AssetBundle, loaded +native object)* pair — i.e. every asset an `AssetBundle` keeps loaded in memory, attributed to the +bundle that references it. Use it to **list the actual assets** held by bundles (the utilization view +only counts/sums them per bundle). It applies the **same edge filter** as `v_assetbundle_utilization` +(`native_object → native_object` `native_connection` edges with `to_index <> from_index`), so the +bundle's own native self-reference and its managed wrapper(s) are excluded — every row is a genuine +*other* loaded asset, with no magic numbers. A bundle that holds nothing (an "empty" bundle) produces +**no rows** here. A shared asset referenced by N bundles appears in N rows. + +| Column | Type | Notes | +|--------|------|-------| +| `bundle_index` | INTEGER | The `AssetBundle`'s `native_objects.native_object_index`. | +| `bundle_name` | VARCHAR | The bundle's object name. | +| `bundle_size_bytes` | BIGINT | The bundle object's own native size. | +| `bundle_resident_bytes` | BIGINT | The bundle's resident bytes (format ≥ 17), else NULL. | +| `asset_index` | INTEGER | The loaded object's `native_objects.native_object_index`. | +| `asset_name` | VARCHAR | The loaded object's name. | +| `asset_type_name` | VARCHAR | The loaded object's native type (e.g. `Texture2D`, `Mesh`). | +| `asset_size_bytes` | BIGINT | The loaded object's own native size. | +| `asset_resident_bytes` | BIGINT | The loaded object's resident bytes (format ≥ 17), else NULL. | +| `asset_is_destroyed` | BOOLEAN | Loaded object marked destroyed but still resident. | + +```sql +-- Every asset a specific bundle keeps loaded, biggest first. +SELECT asset_type_name, asset_name, ROUND(asset_size_bytes / 1048576.0, 2) AS asset_mb +FROM v_assetbundle_loaded_assets +WHERE bundle_name = 'characters' ORDER BY asset_size_bytes DESC; + +-- What kinds of assets do bundles pull into memory, and how much? +SELECT asset_type_name, COUNT(*) AS assets, + ROUND(SUM(asset_size_bytes) / 1048576.0, 1) AS total_mb +FROM v_assetbundle_loaded_assets GROUP BY 1 ORDER BY total_mb DESC; + +-- Assets shared across multiple bundles (counted under each). +SELECT asset_name, asset_type_name, COUNT(DISTINCT bundle_index) AS bundles +FROM v_assetbundle_loaded_assets GROUP BY 1, 2 HAVING bundles > 1 ORDER BY bundles DESC; +``` + +> Like `v_assetbundle_utilization`, this reflects Unity's flattened bundle→contained-object edges: +> it is the set of **directly-referenced** loaded objects (comprehensive for bundles), not transitive +> retained reachability, and `asset_size_bytes` is each object's **own** size. + ### `region_allocations(region_name)` (DuckDB macro) All `v_allocation_enriched` rows for one OS region: `SELECT * FROM region_allocations('MALLOC_NANO');`