diff --git a/.github/sql/ci_execute_report_views.sql b/.github/sql/ci_execute_report_views.sql new file mode 100644 index 000000000..df1c7488e --- /dev/null +++ b/.github/sql/ci_execute_report_views.sql @@ -0,0 +1,76 @@ +/* +Copyright 2026 Darling Data, LLC +https://www.erikdarling.com/ + +CI: execute every report.* view against the seeded rows (#1669). + +Existence checks (OBJECT_ID) let two view bugs ship: #1635's bit -> nvarchar conversion (Msg 245) +and #1666's binary(8) boxed raw into sql_variant. Both compile fine and only fail (or corrupt) +when rows flow through the projection. This sweep runs AFTER ci_seed_report_sources.sql so rows +exist, and materializes each view with SELECT * INTO - a plain COUNT(*) would let the optimizer +prune the projected columns and skip exactly the per-row conversions this exists to catch. + +Enumerates sys.views dynamically: a new report view is covered the day it ships, and views that +only exist post-collection (the dynamically-built report.query_snapshots pair) are simply absent +on a fresh install rather than hardcoded failures. Views whose WHERE excludes every seed row still +execute their plan shape; they just prove less - the seeds aim rows at the windows the views read. +*/ + +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +SET NOCOUNT ON; +GO + +USE PerformanceMonitor; +GO + +DECLARE + @view_name sysname, + @sql nvarchar(max), + @failed integer = 0, + @executed integer = 0, + @failures nvarchar(max) = N''; + +DECLARE view_sweep CURSOR LOCAL FAST_FORWARD FOR + SELECT + v.name + FROM sys.views AS v + WHERE SCHEMA_NAME(v.schema_id) = N'report' + ORDER BY v.name; + +OPEN view_sweep; +FETCH NEXT FROM view_sweep INTO @view_name; + +WHILE @@FETCH_STATUS = 0 +BEGIN + /* SELECT * INTO forces every projected column to be evaluated for every row - + the temp table is scoped to the EXEC batch and vanishes with it. */ + SET @sql = N'SELECT * INTO #ci_sink FROM report.' + QUOTENAME(@view_name) + N';'; + + BEGIN TRY + EXECUTE sys.sp_executesql @sql; + SET @executed += 1; + END TRY + BEGIN CATCH + SET @failed += 1; + SET @failures += + NCHAR(10) + N' report.' + @view_name + + N' -> Msg ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + + N': ' + ERROR_MESSAGE(); + PRINT N'FAIL: report.' + @view_name + N' -> Msg ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N': ' + ERROR_MESSAGE(); + END CATCH; + + FETCH NEXT FROM view_sweep INTO @view_name; +END; + +CLOSE view_sweep; +DEALLOCATE view_sweep; + +PRINT N'Report view execution sweep: ' + CAST(@executed AS nvarchar(10)) + N' executed clean, ' + CAST(@failed AS nvarchar(10)) + N' failed.'; + +IF @failed > 0 +BEGIN + DECLARE @error_message nvarchar(2048) = N'Report view execution sweep failed for ' + CAST(@failed AS nvarchar(10)) + N' view(s):' + LEFT(@failures, 1900); + THROW 50069, @error_message, 1; +END; +GO diff --git a/.github/sql/ci_generate_seed_rows.sql b/.github/sql/ci_generate_seed_rows.sql new file mode 100644 index 000000000..f940d22ed --- /dev/null +++ b/.github/sql/ci_generate_seed_rows.sql @@ -0,0 +1,58 @@ +/* +Copyright 2026 Darling Data, LLC +https://www.erikdarling.com/ + +DEV TOOL - not run by CI. Regenerates the generated section of ci_seed_report_sources.sql from a +database carrying the current install schema: run it there, paste the PRINTed INSERTs between the +"generated" markers, and keep the hand-authored history pairs at the bottom of that file (#1669). +*/ + +SET NOCOUNT ON; +/* Generate one INSERT per collect/config base table: NOT NULL, non-identity, non-computed columns. + Values are type-driven; time-ish columns land 5 minutes ago so "today"/"last hour" views see them. */ +DECLARE @sql nvarchar(max); +DECLARE t CURSOR LOCAL FAST_FORWARD FOR + SELECT s.name, tb.name + FROM sys.tables AS tb + JOIN sys.schemas AS s ON s.schema_id = tb.schema_id + WHERE s.name IN (N'collect', N'config') + AND tb.is_ms_shipped = 0 + ORDER BY s.name, tb.name; +DECLARE @s sysname, @tb sysname; +OPEN t; +FETCH NEXT FROM t INTO @s, @tb; +WHILE @@FETCH_STATUS = 0 +BEGIN + DECLARE @cols nvarchar(max) = N'', @vals nvarchar(max) = N''; + SELECT + @cols += CASE WHEN @cols = N'' THEN N'' ELSE N', ' END + QUOTENAME(c.name), + @vals += CASE WHEN @vals = N'' THEN N'' ELSE N', ' END + + CASE + WHEN c.name = N'severity' THEN N'N''CRITICAL''' + WHEN tp.name = N'datetimeoffset' THEN N'SYSDATETIMEOFFSET()' + WHEN c.name IN (N'query_text', N'query_plan_text', N'query_sql_text', N'statement_text', N'plan_xml_compressed') AND tp.name IN (N'varbinary') + THEN N'COMPRESS(N''SELECT 1 AS ci_seed;'')' + WHEN tp.name IN (N'nvarchar', N'varchar', N'sysname', N'nchar', N'char') THEN N'N''ci''' + WHEN tp.name IN (N'datetime2', N'datetime', N'smalldatetime') THEN N'DATEADD(MINUTE, -5, SYSDATETIME())' + WHEN tp.name = N'date' THEN N'CONVERT(date, SYSDATETIME())' + WHEN tp.name = N'time' THEN N'CONVERT(time, SYSDATETIME())' + WHEN tp.name IN (N'bit') THEN N'1' + WHEN tp.name IN (N'int', N'bigint', N'smallint', N'tinyint', N'decimal', N'numeric', N'float', N'real', N'money') THEN N'1' + WHEN tp.name IN (N'varbinary', N'binary') THEN N'0x00' + WHEN tp.name = N'uniqueidentifier' THEN N'NEWID()' + WHEN tp.name = N'xml' THEN N'CONVERT(xml, N'''')' + ELSE N'NULL /* unhandled: ' + tp.name + N' */' + END + FROM sys.columns AS c + JOIN sys.types AS tp ON tp.user_type_id = c.user_type_id + WHERE c.object_id = OBJECT_ID(QUOTENAME(@s) + N'.' + QUOTENAME(@tb)) + AND c.is_identity = 0 + AND c.is_computed = 0 + AND c.is_nullable = 0; + IF @cols <> N'' + PRINT N'IF NOT EXISTS (SELECT 1/0 FROM ' + QUOTENAME(@s) + N'.' + QUOTENAME(@tb) + N' WHERE 1 = 1) INSERT INTO ' + QUOTENAME(@s) + N'.' + QUOTENAME(@tb) + N' (' + @cols + N') VALUES (' + @vals + N');'; + ELSE + PRINT N'/* ' + QUOTENAME(@s) + N'.' + QUOTENAME(@tb) + N': all columns nullable/identity - DEFAULT VALUES */'; + FETCH NEXT FROM t INTO @s, @tb; +END; +CLOSE t; DEALLOCATE t; diff --git a/.github/sql/ci_seed_report_sources.sql b/.github/sql/ci_seed_report_sources.sql new file mode 100644 index 000000000..ba356c468 --- /dev/null +++ b/.github/sql/ci_seed_report_sources.sql @@ -0,0 +1,121 @@ +/* +Copyright 2026 Darling Data, LLC +https://www.erikdarling.com/ + +CI seed rows for the report.* view execution sweep (#1669). + +The existence check alone let two view bugs ship (#1635's bit->nvarchar Msg 245, #1666's binary +boxed into sql_variant): a view can compile and exist yet throw only when rows flow through its +projection. These seeds put one representative row in every collect/config table (generated from +the installed schema: every NOT NULL, non-identity, non-computed column gets a type-appropriate +value; nullable columns stay NULL so NULL branches execute too), timestamped a few minutes back so +"today"/"last hour" windows include them. History tables that feed LAG/toggle views get a +hand-authored correlated PAIR below, so the change-detection paths produce actual output rows. + +Each INSERT is guarded on the table being empty: re-runnable anywhere, inert on a store that +already has data. CI runs this after a fresh install, immediately before ci_execute_report_views.sql. + +Regenerating after schema changes: the generator lives in the PR that added this (see #1669) - +metadata-driven, so adding a table means re-running it or hand-adding one guarded INSERT here. +*/ + +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +SET NOCOUNT ON; +GO + +USE PerformanceMonitor; +GO + +/* ---------------- generated: one guarded row per collect/config table ---------------- */ + +IF NOT EXISTS (SELECT 1/0 FROM [collect].[blocked_process_xml] WHERE 1 = 1) INSERT INTO [collect].[blocked_process_xml] ([collection_time], [blocked_process_xml], [is_processed]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), CONVERT(xml, N''), 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[blocking_BlockedProcessReport] WHERE 1 = 1) INSERT INTO [collect].[blocking_BlockedProcessReport] ([collection_time], [blocked_process_report]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci'); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[blocking_deadlock_stats] WHERE 1 = 1) INSERT INTO [collect].[blocking_deadlock_stats] ([collection_time], [database_name], [blocking_event_count], [total_blocking_duration_ms], [max_blocking_duration_ms], [deadlock_count], [total_deadlock_wait_time_ms], [victim_count]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[cpu_scheduler_stats] WHERE 1 = 1) INSERT INTO [collect].[cpu_scheduler_stats] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[cpu_utilization_stats] WHERE 1 = 1) INSERT INTO [collect].[cpu_utilization_stats] ([collection_time], [sample_time], [sqlserver_cpu_utilization]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[database_size_stats] WHERE 1 = 1) INSERT INTO [collect].[database_size_stats] ([collection_time], [database_name], [database_id], [file_id], [file_type_desc], [file_name], [physical_name], [total_size_mb]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, N'ci', N'ci', N'ci', 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[deadlock_xml] WHERE 1 = 1) INSERT INTO [collect].[deadlock_xml] ([collection_time], [deadlock_xml], [is_processed]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), CONVERT(xml, N''), 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[deadlocks] WHERE 1 = 1) INSERT INTO [collect].[deadlocks] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[default_trace_events] WHERE 1 = 1) INSERT INTO [collect].[default_trace_events] ([collection_time], [event_time], [event_name], [event_class]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[dmv_blocking_snapshots] WHERE 1 = 1) INSERT INTO [collect].[dmv_blocking_snapshots] ([collection_time], [monitor_loop], [event_time], [spid], [ecid], [blocking_spid], [blocking_ecid]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[file_io_stats] WHERE 1 = 1) INSERT INTO [collect].[file_io_stats] ([collection_time], [server_start_time], [database_id], [file_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_CPUTasks] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_CPUTasks] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_IOIssues] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_IOIssues] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_MemoryBroker] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_MemoryBroker] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_MemoryConditions] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_MemoryConditions] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_MemoryNodeOOM] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_MemoryNodeOOM] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SchedulerIssues] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SchedulerIssues] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SevereErrors] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SevereErrors] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SignificantWaits] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SignificantWaits] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SystemHealth] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SystemHealth] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_WaitsByCount] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_WaitsByCount] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_WaitsByDuration] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_WaitsByDuration] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[index_object_stats] WHERE 1 = 1) INSERT INTO [collect].[index_object_stats] ([collection_time], [database_name], [database_id], [schema_name], [object_id], [table_name], [index_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, N'ci', 1, N'ci', 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[latch_stats] WHERE 1 = 1) INSERT INTO [collect].[latch_stats] ([collection_time], [server_start_time], [latch_class], [waiting_requests_count], [wait_time_ms], [max_wait_time_ms]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_clerks_stats] WHERE 1 = 1) INSERT INTO [collect].[memory_clerks_stats] ([collection_time], [server_start_time], [clerk_type], [memory_node_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_grant_stats] WHERE 1 = 1) INSERT INTO [collect].[memory_grant_stats] ([collection_time], [server_start_time], [resource_semaphore_id], [pool_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_pressure_events] WHERE 1 = 1) INSERT INTO [collect].[memory_pressure_events] ([collection_time], [sample_time], [memory_notification], [memory_indicators_process], [memory_indicators_system]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_stats] WHERE 1 = 1) INSERT INTO [collect].[memory_stats] ([collection_time], [buffer_pool_mb], [plan_cache_mb], [other_memory_mb], [total_memory_mb], [physical_memory_in_use_mb], [available_physical_memory_mb], [memory_utilization_percentage], [buffer_pool_pressure_warning], [plan_cache_pressure_warning]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[perfmon_stats] WHERE 1 = 1) INSERT INTO [collect].[perfmon_stats] ([collection_time], [server_start_time], [object_name], [counter_name], [instance_name], [cntr_value], [cntr_type]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[plan_cache_stats] WHERE 1 = 1) INSERT INTO [collect].[plan_cache_stats] ([collection_time], [cacheobjtype], [objtype], [total_plans], [total_size_mb], [single_use_plans], [single_use_size_mb], [multi_use_plans], [multi_use_size_mb], [avg_use_count], [avg_size_kb]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 1, 1, 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[procedure_stats] WHERE 1 = 1) INSERT INTO [collect].[procedure_stats] ([collection_time], [server_start_time], [object_type], [database_name], [object_id], [sql_handle], [plan_handle], [cached_time], [last_execution_time], [execution_count], [total_worker_time], [min_worker_time], [max_worker_time], [total_elapsed_time], [min_elapsed_time], [max_elapsed_time], [total_logical_reads], [min_logical_reads], [max_logical_reads], [total_physical_reads], [min_physical_reads], [max_physical_reads], [total_logical_writes], [min_logical_writes], [max_logical_writes]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 1, 0x00, 0x00, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[procedure_stats_latest_hash] WHERE 1 = 1) INSERT INTO [collect].[procedure_stats_latest_hash] ([database_name], [object_id], [plan_handle], [row_hash], [last_seen]) VALUES (N'ci', 1, 0x00, 0x00, DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_stats] WHERE 1 = 1) INSERT INTO [collect].[query_stats] ([collection_time], [server_start_time], [object_type], [database_name], [sql_handle], [statement_start_offset], [statement_end_offset], [plan_generation_num], [plan_handle], [creation_time], [last_execution_time], [execution_count], [total_worker_time], [min_worker_time], [max_worker_time], [total_physical_reads], [min_physical_reads], [max_physical_reads], [total_logical_writes], [total_logical_reads], [total_clr_time], [total_elapsed_time], [min_elapsed_time], [max_elapsed_time], [total_rows], [min_rows], [max_rows], [min_dop], [max_dop], [min_grant_kb], [max_grant_kb], [min_used_grant_kb], [max_used_grant_kb], [min_ideal_grant_kb], [max_ideal_grant_kb], [min_reserved_threads], [max_reserved_threads], [min_used_threads], [max_used_threads], [total_spills], [min_spills], [max_spills]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 0x00, 1, 1, 1, 0x00, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_stats_latest_hash] WHERE 1 = 1) INSERT INTO [collect].[query_stats_latest_hash] ([sql_handle], [statement_start_offset], [statement_end_offset], [plan_handle], [row_hash], [last_seen]) VALUES (0x00, 1, 1, 0x00, 0x00, DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_store_data] WHERE 1 = 1) INSERT INTO [collect].[query_store_data] ([collection_time], [database_name], [query_id], [plan_id], [utc_first_execution_time], [utc_last_execution_time], [server_first_execution_time], [server_last_execution_time], [count_executions], [avg_duration], [min_duration], [max_duration], [avg_cpu_time], [min_cpu_time], [max_cpu_time], [avg_logical_io_reads], [min_logical_io_reads], [max_logical_io_reads], [avg_logical_io_writes], [min_logical_io_writes], [max_logical_io_writes], [avg_physical_io_reads], [min_physical_io_reads], [max_physical_io_reads], [avg_clr_time], [min_clr_time], [max_clr_time], [min_dop], [max_dop], [avg_query_max_used_memory], [min_query_max_used_memory], [max_query_max_used_memory], [avg_rowcount], [min_rowcount], [max_rowcount], [is_forced_plan]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, SYSDATETIMEOFFSET(), SYSDATETIMEOFFSET(), DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_store_data_latest_hash] WHERE 1 = 1) INSERT INTO [collect].[query_store_data_latest_hash] ([database_name], [query_id], [plan_id], [row_hash], [last_seen]) VALUES (N'ci', 1, 1, 0x00, DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[running_jobs] WHERE 1 = 1) INSERT INTO [collect].[running_jobs] ([collection_time], [server_start_time], [job_name], [job_id], [job_enabled], [start_time], [current_duration_seconds], [is_running_long]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', NEWID(), 1, DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[server_properties] WHERE 1 = 1) INSERT INTO [collect].[server_properties] ([collection_time], [server_name], [edition], [product_version], [product_level], [engine_edition], [cpu_count], [hyperthread_ratio], [physical_memory_mb]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', N'ci', 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[session_stats] WHERE 1 = 1) INSERT INTO [collect].[session_stats] ([collection_time], [total_sessions], [running_sessions], [sleeping_sessions], [background_sessions], [dormant_sessions], [idle_sessions_over_30min], [sessions_waiting_for_memory], [databases_with_connections]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[spinlock_stats] WHERE 1 = 1) INSERT INTO [collect].[spinlock_stats] ([collection_time], [server_start_time], [spinlock_name], [collisions], [spins], [spins_per_collision], [sleep_time], [backoffs]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[tempdb_stats] WHERE 1 = 1) INSERT INTO [collect].[tempdb_stats] ([collection_time], [user_object_reserved_page_count], [internal_object_reserved_page_count], [version_store_reserved_page_count], [mixed_extent_page_count], [unallocated_extent_page_count], [total_sessions_using_tempdb], [sessions_with_user_objects], [sessions_with_internal_objects], [version_store_high_warning], [allocation_contention_warning]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[trace_analysis] WHERE 1 = 1) INSERT INTO [collect].[trace_analysis] ([collection_time], [trace_file_name], [event_class], [event_name]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, N'ci'); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[wait_stats] WHERE 1 = 1) INSERT INTO [collect].[wait_stats] ([collection_time], [server_start_time], [wait_type], [waiting_tasks_count], [wait_time_ms], [signal_wait_time_ms]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [collect].[waiting_tasks] WHERE 1 = 1) INSERT INTO [collect].[waiting_tasks] ([collection_time], [session_id], [wait_type], [wait_duration_ms], [blocking_session_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, N'ci', 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [config].[collection_log] WHERE 1 = 1) INSERT INTO [config].[collection_log] ([collection_time], [collector_name], [collection_status], [rows_collected], [duration_ms]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [config].[collection_schedule] WHERE 1 = 1) INSERT INTO [config].[collection_schedule] ([collector_name], [enabled], [frequency_minutes], [max_duration_minutes], [retention_days], [collect_query], [collect_plan], [created_date], [modified_date]) VALUES (N'ci', 1, 1, 1, 1, 1, 1, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [config].[collector_database_exclusions] WHERE 1 = 1) INSERT INTO [config].[collector_database_exclusions] ([database_name], [excluded_at]) VALUES (N'ci', DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [config].[critical_issues] WHERE 1 = 1) INSERT INTO [config].[critical_issues] ([log_date], [severity], [problem_area], [source_collector], [message]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'CRITICAL', N'ci', N'ci', N'ci'); +IF NOT EXISTS (SELECT 1/0 FROM [config].[database_configuration_history] WHERE 1 = 1) INSERT INTO [config].[database_configuration_history] ([collection_time], [database_id], [database_name], [setting_type], [setting_name]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, N'ci', N'ci', N'ci'); +IF NOT EXISTS (SELECT 1/0 FROM [config].[ignored_wait_types] WHERE 1 = 1) INSERT INTO [config].[ignored_wait_types] ([wait_type], [is_enabled], [created_date], [modified_date]) VALUES (N'ci', 1, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME())); +IF NOT EXISTS (SELECT 1/0 FROM [config].[installation_history] WHERE 1 = 1) INSERT INTO [config].[installation_history] ([installation_date], [installer_version], [sql_server_version], [sql_server_edition], [installation_type], [installation_status]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', N'ci', N'ci'); +IF NOT EXISTS (SELECT 1/0 FROM [config].[server_configuration_history] WHERE 1 = 1) INSERT INTO [config].[server_configuration_history] ([collection_time], [configuration_id], [configuration_name], [is_dynamic], [is_advanced]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, N'ci', 1, 1); +IF NOT EXISTS (SELECT 1/0 FROM [config].[server_info_history] WHERE 1 = 1) INSERT INTO [config].[server_info_history] ([collection_time], [sqlserver_start_time], [server_name], [sql_version], [edition], [physical_memory_mb], [cpu_count], [environment_type]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', 1, 1, N'ci'); +IF NOT EXISTS (SELECT 1/0 FROM [config].[trace_flags_history] WHERE 1 = 1) INSERT INTO [config].[trace_flags_history] ([collection_time], [trace_flag], [status], [is_global], [is_session]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1); + +GO + +/* ---------------- hand-authored history PAIRS for the LAG/toggle views ---------------- */ + +/* Trace flag toggled OFF -> ON across two collections: report.trace_flag_changes' LAG path emits a + row, executing the bit -> status-text projection that regressed in #1635/#1660. */ +IF NOT EXISTS (SELECT 1/0 FROM config.trace_flags_history WHERE trace_flag = 8675) +BEGIN + INSERT INTO config.trace_flags_history (collection_time, trace_flag, status, is_global, is_session) + VALUES (DATEADD(MINUTE, -10, SYSDATETIME()), 8675, 0, 1, 0); + + INSERT INTO config.trace_flags_history (collection_time, trace_flag, status, is_global, is_session) + VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 8675, 1, 1, 0); +END; + +/* Server-level sp_configure value changed between collections (sql_variant old/new projection). */ +IF NOT EXISTS (SELECT 1/0 FROM config.server_configuration_history WHERE configuration_name = N'ci seed knob') +BEGIN + INSERT INTO config.server_configuration_history (collection_time, configuration_id, configuration_name, value_configured, value_in_use, is_dynamic, is_advanced) + VALUES (DATEADD(MINUTE, -10, SYSDATETIME()), 999, N'ci seed knob', CONVERT(sql_variant, 0), CONVERT(sql_variant, 0), 1, 0); + + INSERT INTO config.server_configuration_history (collection_time, configuration_id, configuration_name, value_configured, value_in_use, is_dynamic, is_advanced) + VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 999, N'ci seed knob', CONVERT(sql_variant, 1), CONVERT(sql_variant, 1), 1, 0); +END; + +/* Database-scoped setting changed between collections. */ +IF NOT EXISTS (SELECT 1/0 FROM config.database_configuration_history WHERE database_name = N'ci_seed_db') +BEGIN + INSERT INTO config.database_configuration_history (collection_time, database_id, database_name, setting_type, setting_name, setting_value) + VALUES (DATEADD(MINUTE, -10, SYSDATETIME()), 999, N'ci_seed_db', 'SCOPED', N'ci seed setting', CONVERT(sql_variant, 0)); + + INSERT INTO config.database_configuration_history (collection_time, database_id, database_name, setting_type, setting_name, setting_value) + VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 999, N'ci_seed_db', 'SCOPED', N'ci seed setting', CONVERT(sql_variant, 1)); +END; +GO diff --git a/.github/workflows/sql-validation.yml b/.github/workflows/sql-validation.yml index 42a4b9dfb..c6fc01fc7 100644 --- a/.github/workflows/sql-validation.yml +++ b/.github/workflows/sql-validation.yml @@ -13,6 +13,11 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false + # SQL Server 2016 is the documented minimum but cannot appear here: SQL Server on Linux + # begins at 2017, so no 2016 container image exists for the ubuntu service-container shape. + # 2016 gets the SAME install + seed + view-execution validation manually against the local + # SQL2016 box as part of release testing (last full pass: 2026-07-26, 13.0.6300 - install + # clean, 41 views executed, 0 failed). matrix: include: - version: '2017' @@ -77,3 +82,20 @@ jobs: SA_PASSWORD: CI_Test#2026! run: | /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_validate_installation.sql + + # Existence alone let two view bugs ship (#1635, #1666): a view can compile yet throw only + # when rows flow through its projection. Seed one representative row per source table + # (history tables get a correlated pair so LAG/toggle paths emit rows), then materialize + # every report.* view with SELECT * INTO - COUNT(*) would prune the projected columns and + # skip exactly the per-row conversions this exists to catch (#1669). + - name: Seed report view sources + env: + SA_PASSWORD: CI_Test#2026! + run: | + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_seed_report_sources.sql + + - name: Execute report views against seeded rows + env: + SA_PASSWORD: CI_Test#2026! + run: | + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_execute_report_views.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf905137..07332cdbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **CI: every `report.*` view now EXECUTES against seeded rows, not just exists** ([#1677]) - the SQL-validation workflow checked the report views by `OBJECT_ID` only, which let two bugs of the same class ship: #1635's per-row bit->nvarchar conversion error and #1666's binary(8) boxed raw into sql_variant - both compile fine and only misbehave when rows flow through the projection. A generated seed script now puts one representative row in every collect/config table after the fresh CI install (NOT NULL columns get type-appropriate values, nullable columns stay NULL so those branches run, compressed-LOB columns get real COMPRESS() payloads, and the change-history tables get correlated toggle PAIRS so the LAG-based views emit actual rows), and a sweep then materializes every `report.*` view with `SELECT * INTO` - COUNT(*) would let the optimizer prune the projection and skip exactly the per-row conversions the sweep exists to catch. Views are enumerated from `sys.views` at run time, so new views are covered the day they ship and the dynamically-built `report.query_snapshots` pair is simply absent on a fresh install rather than a hardcoded failure. Failures list every broken view with its error and fail the job. Detection power proven with a planted per-row Msg 245 view: green sweep without it, red with it. Runs across the whole 2017/2019/2022/2025 matrix; a regeneration dev-tool script rides along for future schema changes. - **Connection alerts for servers that are already down, and re-alerts during a standing outage** ([#1674]) - closes #1659, the gap split out of #1535: connection alerts were pure edge detection, so an app or service that started while a server was already unreachable never announced the outage (no edge existed), and a standing outage produced exactly one alert however long it lasted - which silently broke the reporter's webhook-driven auto-heal loop the day the app restarted mid-outage. Two OPT-INS, both default-off so the classic one-alert-per-outage behavior is untouched: **alert at first sight** (announce a server already down on the first-ever observation) and **re-alert every N minutes while still down** (0 = off). Re-fires deliver under the SAME `Server Unreachable` metric name deliberately - webhook automation keyed on the metric re-triggers, which is the whole point - with the detail text marking the flavor (`Already unreachable when monitoring started` / `Still unreachable (re-alerting every N min)`). The decision is ONE shared definition (`ConnectionAlertPolicy` in PerformanceMonitor.Common, replacing Lite's `ConnectionEdgeDetector` and the inline machine in Darling's `DarlingSelfAlertEvaluator` - the `SqlErrorClassification` discipline, pinned from both test suites), and the two opt-ins interlock: even with the startup announcement off, re-fire alone re-announces after a mid-outage restart, because the re-baselined outage has no recorded down alert and is due immediately. The re-fire clock stamps on DELIVERY only, so an alert suppressed by the notify toggles never consumes the window. Lite: two settings beside the existing connection toggle (settings.json + Settings window). Darling: V33 store columns on `config_alert_settings` (read live like the V20 toggle; refire clamped 0-1440), editable from the viewer's Settings window; no ACL/provisioning change (the table carries table-level grants, no column carve). - **Darling: `--configure-network` can now expose the WEB DASHBOARD** ([#1617]) - the wizard offered Store and MCP but not the web dashboard, even though `--enable-web`'s own output told operators to run `--configure-network` to expose it on the LAN - a dead end that forced hand-editing `web.network` into darling.json. Web is now a first-class third surface, fully symmetric with Store/MCP: its own menu choice (plus comma combinations like `1,3`, and `4` = all three), a keep-or-generate DPAPI access token, listen/CIDR inputs validated by the SAME bind resolver the web host fail-closes on (extracted as `ResolveWebBind`, the web twin of `ResolveMcpBind` - never a reimplementation), the comment-preserving `web.network` write, a one-time token print, and next-steps text including the browser login URL (`http://:/?token=...`, exchanged for a session cookie). Disable now removes all three network blocks. After the wizard, `--enable-web` opens the scoped firewall rule on the first try - no hand-editing required. - **Darling Web: adaptive `auto` time bucket for composed time-series panels** ([#1619]) - a compose custom-view time-series panel can set `timeBucket: "auto"`, and the compiler resolves it to a concrete grain from the panel's window (minute up to 2 days, hour up to 60 days, day beyond) so any range from 1h to 90d renders a readable line and never trips the 5,000-bucket ceiling. Previously a fixed `hour` bucket collapsed a sub-hour workload (e.g. a 30-minute HammerDB run) into a single invisible point, while a fixed `minute` bucket errored past ~3.5 days. The composer now defaults new time-series panels to `auto` and the MCP `describe_custom_view_catalog` recommends it; non-auto buckets compile byte-for-byte as before. Pinned by `DarlingComposeTests` (auto boundary resolution + compile-by-window + non-auto passthrough). @@ -576,6 +577,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1668]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1668 [#1670]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1670 [#1675]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1675 +[#1677]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1677 [#1676]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1676 [#1640]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1640 [#1642]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1642