diff --git a/scripts/workspace-runtime-audit/README.md b/scripts/workspace-runtime-audit/README.md new file mode 100644 index 0000000000..b7ad33f87c --- /dev/null +++ b/scripts/workspace-runtime-audit/README.md @@ -0,0 +1,33 @@ +# Workspace Runtime Audit + +> [!WARNING] +> Do not run this script unless specifically instructed to do so by Coder support or engineering. +> +> Always run this script from a postgres user with read-only access to the database. + +A SQL script that analyzes workspace builds to determine how long each workspace spent in a "running" state. It tracks state transitions (start/stop/delete) and calculates the cumulative runtime, only counting time spent inside the audit window period. + +## Usage + +**1:** Edit the date range in `workspace-runtime-audit.sql`: + +```sql +start_time TIMESTAMPTZ := '2025-12-01 00:00:00+00'; +end_time TIMESTAMPTZ := '2025-12-31 23:59:59+00'; +``` + +**2:** Run against your Coder database: + +```bash +psql -d coder -f scripts/workspace-runtime-audit/workspace-runtime-audit.sql +``` + +**3:** Review the output csv at `workspace_usage.csv`. + +## Output + +| Column | Type | Description | +|------------------------|-------------|----------------------------------------------------| +| `workspace_id` | timestamptz | Name of the workspace | +| `workspace_created_at` | timestamptz | When the workspace was originally created | +| `usage_hours` | int | Total number of usage hours within the time window | diff --git a/scripts/workspace-runtime-audit/runtimeaudit_test.go b/scripts/workspace-runtime-audit/runtimeaudit_test.go new file mode 100644 index 0000000000..926fa79a63 --- /dev/null +++ b/scripts/workspace-runtime-audit/runtimeaudit_test.go @@ -0,0 +1,429 @@ +// Package runtimeaudit_test implements a test runner for the workspace runtime audit SQL script. +// It is intentionally not named with `_test.go` suffix to avoid running `go test` automatically. +// +// This script is not recommend to be used without guidance from the Coder team. +package runtimeaudit_test + +import ( + "database/sql" + _ "embed" + "math" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" +) + +//go:embed workspace-runtime-audit.sql +var runtimeAuditScript string + +type auditRow struct { + WorkspaceID uuid.UUID + WorkspaceCreatedAt time.Time + UsageHours int +} + +func TestRuntimeAudit(t *testing.T) { + t.Parallel() + + // Cannot run `/copy` meta command over Exec, so comment it out. + runtimeAuditScript = strings.ReplaceAll(runtimeAuditScript, "\\copy", "-- \\copy") + + // Use the SELECT instead + runtimeAuditScript = strings.ReplaceAll(runtimeAuditScript, "-- SELECT * FROM _workspace_usage_results", "SELECT * FROM _workspace_usage_results") + + db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + + // Setup database with some workspace runtimes + s := initSetup(t, db) + startPeriod := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) + endPeriod := time.Date(2025, 12, 31, 23, 58, 59, 0, time.UTC) + + // Shorthands (keeps vectors readable). + decUTC := func(d, h, m int) time.Time { return time.Date(2025, 12, d, h, m, 0, 0, time.UTC) } + novUTC := func(d, h, m int) time.Time { return time.Date(2025, 11, d, h, m, 0, 0, time.UTC) } + janUTC := func(d, h, m int) time.Time { return time.Date(2026, 1, d, h, m, 0, 0, time.UTC) } + + roundUpHours := func(end, start time.Time) int { + return int(math.Ceil(end.Sub(start).Hours())) + } + + type vec struct { + name string + builds []workspaceBuildArgs + expect func(createdAt time.Time, inputs []workspaceBuildArgs) int + } + + vectors := []vec{ + // ------------------------- + // Happy path / core logic + // ------------------------- + + { + name: "long_run_inside_window_start_to_stop_counts_and_rounds", + // Basic succeeded start -> stop within window. + builds: []workspaceBuildArgs{ + {at: decUTC(3, 0, 15), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(20, 12, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[1].at, in[0].at) + }, + }, + { + name: "failed_start_does_not_count_usage", + // Only succeeded starts count; failed start means no accumulation. + builds: []workspaceBuildArgs{ + {at: decUTC(5, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed}, + {at: decUTC(5, 11, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 }, + }, + { + name: "multiple_starts_while_running_ignores_later_start_uses_first_start", + // Second succeeded start is ignored while already "on". + builds: []workspaceBuildArgs{ + {at: decUTC(6, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(6, 10, 5), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(6, 10, 30), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[2].at, in[0].at) + }, + }, + { + name: "delete_transition_treated_as_stop", + // Non-(start+succeeded) transitions behave like stop when running. + builds: []workspaceBuildArgs{ + {at: decUTC(7, 9, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(7, 9, 20), transition: database.WorkspaceTransitionDelete, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[1].at, in[0].at) + }, + }, + + // ------------------------- + // Window clipping + // ------------------------- + + { + name: "started_before_window_clips_start_to_window_start", + // Start before period; only count from startPeriod. + builds: []workspaceBuildArgs{ + {at: novUTC(27, 23, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(1, 1, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[1].at, startPeriod) + }, + }, + { + name: "stopped_after_window_clips_stop_to_window_end", + // Stop after period; only count until endPeriod. + builds: []workspaceBuildArgs{ + {at: decUTC(31, 23, 30), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: janUTC(1, 1, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(endPeriod, in[0].at) + }, + }, + { + name: "window_clips_both_start_and_stop", + // Stop after period; only count until endPeriod. + builds: []workspaceBuildArgs{ + {at: novUTC(27, 23, 30), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: janUTC(1, 8, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { + return roundUpHours(endPeriod, startPeriod) + }, + }, + { + name: "stop_exactly_at_window_start_counts_zero_due_to_strict_gt", + // Script uses `turned_off > start_time`, so equality yields 0. + builds: []workspaceBuildArgs{ + {at: novUTC(30, 23, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: startPeriod, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 }, + }, + + // ------------------------- + // Still running at end + // ------------------------- + + { + name: "started_in_window_no_stop_accumulates_until_window_end", + // Tail case: still on -> add (endPeriod - turned_on). + builds: []workspaceBuildArgs{ + {at: decUTC(10, 8, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { + return roundUpHours(endPeriod, decUTC(10, 8, 0)) + }, + }, + { + name: "started_before_window_no_stop_accumulates_full_window", + // Tail case + clipping: start before period -> treat as startPeriod. + builds: []workspaceBuildArgs{ + {at: novUTC(1, 0, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { + return roundUpHours(endPeriod, startPeriod) + }, + }, + + // ------------------------- + // Multi-segment behavior (single ceil at end) + // ------------------------- + + { + name: "two_segments_sum_then_single_round_up", + // 20m + 20m => 40m total => ceil(total)=1 hour. + builds: []workspaceBuildArgs{ + {at: decUTC(12, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(12, 10, 20), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(12, 11, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(12, 11, 20), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { + return 1 + }, + }, + { + name: "two_segments_sum", + // 20m + 20m => 40m total => ceil(total)=1 hour. + builds: []workspaceBuildArgs{ + {at: decUTC(12, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(12, 15, 20), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + + {at: decUTC(13, 11, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(12, 13, 14), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + + {at: decUTC(14, 7, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(14, 13, 14), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + + {at: decUTC(15, 4, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(15, 8, 14), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + + // Add a failed start/stop to ensure it's ignored. + {at: decUTC(16, 0, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed}, + {at: decUTC(17, 0, 0), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[1].at, in[0].at) + + roundUpHours(in[3].at, in[2].at) + + roundUpHours(in[5].at, in[4].at) + + roundUpHours(in[7].at, in[6].at) + }, + }, + + // ------------------------- + // Outside-the-window activity + // ------------------------- + + { + name: "activity_entirely_before_window_counts_zero", + // Stop before startPeriod => no overlap. + builds: []workspaceBuildArgs{ + {at: novUTC(10, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: novUTC(10, 10, 10), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 }, + }, + { + name: "activity_entirely_after_window_counts_zero", + // Start after endPeriod => no overlap. + builds: []workspaceBuildArgs{ + {at: janUTC(2, 10, 0), transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: janUTC(2, 10, 10), transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 }, + }, + + // ------------------------- + // Canceled / failed builds + // ------------------------- + + { + name: "canceled_start_does_not_count_usage", + // Only start+succeeded counts; canceled start is ignored. + builds: []workspaceBuildArgs{ + {at: decUTC(8, 9, 0), canceled: true, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusCanceled}, + {at: decUTC(8, 10, 0), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 }, + }, + { + name: "failed_start_does_not_count_even_if_later_stop_occurs", + // Start failed => never turns on => later stop does nothing. + builds: []workspaceBuildArgs{ + {at: decUTC(9, 9, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed}, + {at: decUTC(9, 12, 0), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, _ []workspaceBuildArgs) int { return 0 }, + }, + { + name: "canceled_stop_still_stops_timer_and_counts_time", + // Any non-(start+succeeded) is treated as stop while running, regardless of status/canceled. + builds: []workspaceBuildArgs{ + {at: decUTC(10, 9, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(10, 9, 40), canceled: true, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusCanceled}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[1].at, in[0].at) + }, + }, + { + name: "failed_stop_still_stops_timer_and_counts_time", + // Same as above: stop is stop even if job failed (ELSE path). + builds: []workspaceBuildArgs{ + {at: decUTC(11, 10, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(11, 10, 10), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusFailed}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[1].at, in[0].at) + }, + }, + { + name: "failed_transition_stops_timer_and_counts_time", + // A failed *non-stop* transition (e.g. delete) still stops if currently on. + builds: []workspaceBuildArgs{ + {at: decUTC(12, 8, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + {at: decUTC(12, 8, 5), canceled: false, transition: database.WorkspaceTransitionDelete, jobStatus: database.ProvisionerJobStatusFailed}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + return roundUpHours(in[1].at, in[0].at) + }, + }, + { + name: "start_succeeded_then_start_failed_does_not_reset_start_time", + // When already on, a subsequent non-(start+succeeded) build triggers stop logic. + // This verifies you *do not* treat start+failed as a "start"; it will stop the running timer. + builds: []workspaceBuildArgs{ + {at: decUTC(13, 9, 0), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusSucceeded}, + // This goes to ELSE branch (because job_status != succeeded) and will stop the timer. + {at: decUTC(13, 9, 30), canceled: false, transition: database.WorkspaceTransitionStart, jobStatus: database.ProvisionerJobStatusFailed}, + // Subsequent stop should not add more time because timer was reset. + {at: decUTC(13, 10, 0), canceled: false, transition: database.WorkspaceTransitionStop, jobStatus: database.ProvisionerJobStatusSucceeded}, + }, + expect: func(_ time.Time, in []workspaceBuildArgs) int { + // Only counts from first start to failed-start event. + return roundUpHours(in[1].at, in[0].at) + }, + }, + } + + // Create all workspaces + workspaces := make([]database.WorkspaceTable, len(vectors)) + + for i, v := range vectors { + wrk := s.createWorkspace(t, db, v.builds) + workspaces[i] = wrk + } + + row, err := sqlDB.Query(runtimeAuditScript) + require.NoError(t, err) + + found := make(map[uuid.UUID]auditRow) + for row.Next() { + var r auditRow + err = row.Scan(&r.WorkspaceID, &r.WorkspaceCreatedAt, &r.UsageHours) + require.NoError(t, err) + found[r.WorkspaceID] = r + } + + for i, v := range vectors { + t.Run(v.name, func(t *testing.T) { + t.Parallel() + v.expect(workspaces[i].CreatedAt, v.builds) + }) + } +} + +type setup struct { + org database.Organization + usr database.User +} + +func initSetup(t *testing.T, db database.Store) *setup { + usr := dbgen.User(t, db, database.User{}) + org := dbfake.Organization(t, db). + Members(usr). + Do() + return &setup{ + org: org.Org, + usr: usr, + } +} + +type workspaceBuildArgs struct { + at time.Time + canceled bool + transition database.WorkspaceTransition + jobStatus database.ProvisionerJobStatus +} + +func (s *setup) createWorkspace(t *testing.T, db database.Store, builds []workspaceBuildArgs) database.WorkspaceTable { + // Insert the first build + tv := dbfake.TemplateVersion(t, db). + Seed(database.TemplateVersion{ + OrganizationID: s.org.ID, + CreatedBy: s.usr.ID, + }). + Do() + + wrk := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: s.usr.ID, + OrganizationID: s.org.ID, + TemplateID: tv.Template.ID, + CreatedAt: builds[0].at, + }) + + for i, b := range builds { + job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + CreatedAt: b.at, + UpdatedAt: b.at, + StartedAt: sql.NullTime{ + Time: b.at, + Valid: true, + }, + CanceledAt: sql.NullTime{ + Time: b.at, + Valid: b.canceled, + }, + CompletedAt: sql.NullTime{ + Time: b.at, + Valid: true, + }, + Error: sql.NullString{}, + OrganizationID: s.org.ID, + InitiatorID: s.usr.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + JobStatus: b.jobStatus, + }) + + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + CreatedAt: b.at, + UpdatedAt: b.at, + WorkspaceID: wrk.ID, + TemplateVersionID: tv.TemplateVersion.ID, + ///nolint:gosec // this will not overflow + BuildNumber: int32(i) + 1, + Transition: b.transition, + InitiatorID: s.usr.ID, + JobID: job.ID, + }) + } + + return wrk +} diff --git a/scripts/workspace-runtime-audit/workspace-runtime-audit.sql b/scripts/workspace-runtime-audit/workspace-runtime-audit.sql new file mode 100644 index 0000000000..a21e243a0f --- /dev/null +++ b/scripts/workspace-runtime-audit/workspace-runtime-audit.sql @@ -0,0 +1,199 @@ +-- Workspace Runtime Audit Report +-- !! CAUTION: Do not run this script unless specifically instructed to do so by Coder support or engineering. +-- +-- This script calculates total workspace runtime within a specified date range. +-- It tracks workspace state transitions (start/stop/delete) and sums up the time +-- each workspace spent in a "running" state. A "running" state is considered to be +-- any workspace that has been started successfully and not yet stopped, deleted, or failed. +-- +-- All usage (per workspace) is rounded up to the nearest hour. So usage is accumulated for the time period +-- per workspace, then rounded up to the next hour. +-- So a workspace that runs for 40s, will be 1 hour of usage. +-- If a workspace runs for 40s, then stops, then runs for another 40s will still be 1 hour. +-- +-- Usage: +-- 1. Edit the start_time and end_time in the params below +-- 2. Run: psql -f workspace-runtime-audit.sql +-- 3. A file called 'workspace_usage.csv' will be generated with the results. +BEGIN; +-- Temp table to hold the data aggregated from the anonymous function. +-- It is dropped automatically at the end of the transaction. +CREATE TEMP TABLE _workspace_usage_results ( + workspace_id uuid, + workspace_created_at timestamptz, + usage_hours INTEGER +) ON COMMIT DROP; + +DO $$ +DECLARE + -- CHANGE THE START/END TIME HERE FOR YOUR AUDIT + start_time TIMESTAMPTZ := '2025-12-01 00:00:00+00'; + end_time TIMESTAMPTZ := '2025-12-31 23:59:59+00'; + -- 'debug_mode' emits logging to help trace processing for each workspace + debug_mode BOOLEAN := FALSE; + -- temporary variables + workspace RECORD; + workspace_build RECORD; + workspace_turned_on TIMESTAMPTZ; + workspace_turned_off TIMESTAMPTZ; + workspace_usage_duration INTERVAL; + latest_build_created_at TIMESTAMPTZ; + latest_build_transition workspace_transition; + -- Counter for skipped workspaces that are outside the window, used for debugging + skipped_workspaces INTEGER := 0; +BEGIN + FOR workspace IN + SELECT + workspaces.* + FROM + workspaces + -- Adding a WHERE clause here to limit the workspaces is helpful during testing. + LOOP + -- Initialize variables for each workspace and prevent variable carry-over between workspaces. + workspace_turned_on = '0001-01-01 00:00:00+00'; + workspace_usage_duration = 0; + IF debug_mode THEN + RAISE NOTICE 'Processing Workspace ID: %, Created At: %', workspace.id, workspace.created_at; + end if; + + -- Fetch the latest build for the workspace to determine if we can skip it entirely. + SELECT + wb.created_at, wb.transition + FROM + workspace_builds wb + WHERE + wb.workspace_id = workspace.id + ORDER BY wb.build_number DESC + LIMIT 1 + INTO + latest_build_created_at, + latest_build_transition + ; + + -- If the latest build is a stop/delete before our start time, this workspace had no activity in our audit period. + -- This is an optimization to skip processing workspaces that are clearly before the audit period. Workspaces created + -- after the audit period are still processed. Since this is expected to be run periodically, optimizing out old workspaces + -- is likely to be more beneficial than future workspaces. + IF + latest_build_created_at < start_time AND latest_build_transition != 'start' + THEN + skipped_workspaces = skipped_workspaces + 1; + CONTINUE; + END IF; + + -- For every workspace, calculate the total runtime within the specified date range. + FOR workspace_build IN + SELECT + workspace_builds.*, + provisioner_jobs.job_status, + provisioner_jobs.completed_at + FROM workspace_builds + LEFT JOIN provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id + WHERE workspace_id = workspace.id + ORDER BY workspace_builds.build_number ASC + LOOP + -- Algorithm summary: + -- LOOP: + -- 1. When a successful start is found, set 'workspace_turned_on' + -- 1a. If already turned on, ignore (multiple starts without stops) + -- 2. Any other transition (stop/delete/failed) will calculate the duration from 'workspace_turned_on' to this point + -- 3. After the loop, if still turned on, calculate duration from 'workspace_turned_on' to 'end_time' + + -- Usage only counts from workspaces that successfully started + IF workspace_build.transition = 'start' AND + workspace_build.job_status IN ('succeeded') + THEN + -- If the workspace is already turned on (e.g., multiple starts without stops), + -- we consider the previous start time as usage time. So ignore this start. + IF workspace_turned_on = '0001-01-01 00:00:00+00' THEN + workspace_turned_on = COALESCE(workspace_build.completed_at, workspace_build.created_at); + IF debug_mode THEN + RAISE NOTICE 'Workspace (build %) turned ON at %', workspace_build.build_number, workspace_turned_on; + END IF; + END IF; + ELSE + -- All other transitions and job status are treated as a workspace stopping. + -- Only accumulate time from the last successful start to this point. + -- You could imagine a workspace that was only failing builds and never accumulating time. + IF workspace_turned_on != '0001-01-01 00:00:00+00' + THEN + workspace_turned_off = COALESCE(workspace_build.completed_at, workspace_build.created_at); + -- We only track usage within the start_time and end_time range. + IF + -- Turned off before the start time, so this workspace lived and died before our audit period. + workspace_turned_off > start_time AND + -- Turned on before the end time. If this is in the future, then it didn't run during our audit period. + workspace_turned_on < end_time + THEN + -- Fix the on/off times to be within the audit period. Ignore any time outside the period. + IF workspace_turned_on < start_time THEN + -- Started before the audit period, so move the turned_on to start_time. + workspace_turned_on = start_time; + END IF; + + IF workspace_turned_off > end_time THEN + -- Turned off after the audit period, so move the turned_off to end_time. + workspace_turned_off = end_time; + END IF; + + workspace_usage_duration = workspace_usage_duration + (workspace_turned_off - workspace_turned_on); + IF debug_mode THEN + RAISE NOTICE 'Workspace (build %) turning OFF at % with % duration accumulated', + workspace_build.build_number, workspace_turned_off, (workspace_turned_off - workspace_turned_on); + END IF; + ELSE + IF debug_mode THEN + RAISE NOTICE 'Workspace (build %) indicated activity outside the audit period, no duration accumulated', + workspace_build.build_number; + END IF; + END IF; + + -- Always reset turned_on even if the duration was not accumulated. + workspace_turned_on = '0001-01-01 00:00:00+00'; + END IF; + END IF; + END LOOP; + + -- After processing all builds, if the workspace is still turned on, accumulate time until end_time. + -- This handles workspaces that were started but never stopped/deleted. + IF workspace_turned_on != '0001-01-01 00:00:00+00' THEN + IF workspace_turned_on < start_time THEN + workspace_turned_on = start_time; + END IF; + IF workspace_turned_on < end_time THEN + IF debug_mode THEN + RAISE NOTICE 'Workspace still on at end of period, adding %', (end_time - workspace_turned_on); + END IF; + workspace_usage_duration = workspace_usage_duration + (end_time - workspace_turned_on); + END IF; + workspace_turned_on = '0001-01-01 00:00:00+00'; + END IF; + + INSERT INTO _workspace_usage_results ( + workspace_id, + workspace_created_at, + usage_hours + ) + VALUES ( + workspace.id, + workspace.created_at, + -- Only tracking whole hours for simplicity. Always rounding up to the next hour. + CEIL((EXTRACT(EPOCH FROM workspace_usage_duration) / 3600.0)) + ); + + IF debug_mode THEN + RAISE NOTICE 'Workspace ID: %, Created At: % has %d usage', workspace.id, workspace.created_at, EXTRACT(EPOCH FROM workspace_usage_duration) / 3600; + end if; + END LOOP; + IF debug_mode THEN + RAISE NOTICE 'Skipped % workspaces due to latest build before audit period', skipped_workspaces; + END IF; +END$$ LANGUAGE plpgsql; + +-- Export the results to a CSV file +\copy (SELECT * FROM _workspace_usage_results WHERE usage_hours > 0 ORDER BY usage_hours DESC) TO 'workspace_usage.csv' WITH (FORMAT CSV, HEADER TRUE); + +-- Optionally use a select to view results as a table output +-- SELECT * FROM _workspace_usage_results WHERE usage_hours > 0 ORDER BY usage_hours DESC; + +COMMIT;