Schema Management
This document describes the database schema management system for the pgEdge AI Workbench Collector.
Overview
The Collector uses a migration-based schema management system that provides the following capabilities:
- The system automatically creates and updates database schemas at startup.
- The system tracks which migrations have been applied.
- The system ensures migrations are applied in the correct order.
- The system supports idempotent migrations that can run multiple times safely.
- The system creates tables, indexes, constraints, and foreign keys.
Architecture
The schema management system consists of several components that work together to maintain the database schema.
SchemaManager
The SchemaManager struct manages all database migrations. The manager
maintains a registry of all available migrations, determines which migrations
need to be applied, applies pending migrations in order, and tracks migration
status in the database.
Migration
Each Migration struct represents a single schema change and contains the
following fields:
Versionis a unique integer identifying the migration in sequential order.Descriptionis a human-readable description of the migration.Upis a function that applies the migration.
schema_version Table
The schema_version table tracks which migrations have been applied.
CREATE TABLE schema_version (
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL
DEFAULT CURRENT_TIMESTAMP
)
Migration Process
When the Collector starts up, the system executes the following steps:
- The
Datastore.initializeSchema()method is called. - A new
SchemaManageris created with all registered migrations. - The
SchemaManager.Migrate()method queries the current schema version, sorts migrations by version number, applies each pending migration in a transaction, records successful migrations inschema_version, and rolls back on errors.
Current Migrations
The following migrations are currently defined in the system.
Migration 1: Create schema_version Table
This migration creates the schema_version table used to track migrations. The
table contains version, description, and applied_at columns.
Migration 2: Create monitored_connections Table
This migration creates the table that stores PostgreSQL server connection information.
The migration creates the following objects:
- The
monitored_connectionstable stores connection details. - A check constraint on
portensures values between 1 and 65535. - A check constraint on
owner_tokenrequires the token for non-shared connections.
Migration 3: Create Indexes on monitored_connections
This migration creates indexes to optimize common queries.
The migration creates the following indexes:
idx_monitored_connections_owner_tokenindexesowner_tokenfor fast ownership lookups.idx_monitored_connections_is_monitoredis a partial index on actively monitored connections.idx_monitored_connections_nameindexes connection name for fast lookups.
Migration 4: Create probes Table
This migration creates the table that defines monitoring probes. The table
includes check constraints on collection_interval and retention_days, and a
unique constraint on name.
Migration 5: Create Indexes on probes
This migration creates indexes to optimize probe queries. The migration creates
idx_probes_enabled as a partial index on enabled probes and idx_probes_name
for fast lookups by probe name.
Migration 22: Add connection_id to probe_configs
This migration adds per-server probe configuration support by adding a
connection_id column to the probe_configs table. The migration creates a
foreign key constraint to connections(id) with CASCADE delete and a composite
unique index on (name, COALESCE(connection_id, 0)).
When connection_id IS NULL, the configuration acts as a global default. When
set, the configuration overrides the default for that specific connection.
Migration 23: Fix unique constraint for global probe configs
This migration fixes a duplicate key constraint issue by replacing the global
unique constraint on probe_configs.name with a partial unique index. The
migration removes probe_configs_name_key and creates
probe_configs_name_global_key as a partial unique index on
name WHERE connection_id IS NULL.
Adding New Migrations
To add a new migration, follow the steps below.
- Edit
schema.goby adding a new migration to theregisterMigrations()method. - Increment the version using the next sequential version number.
- Provide a clear, concise description of the migration.
- Implement the
Upfunction that applies the migration. - Make the migration idempotent by using
IF NOT EXISTSclauses where possible.
Example: Adding a New Table
In the following example, the migration creates a new metrics table:
// Migration 6: Create metrics table
sm.migrations = append(sm.migrations, Migration{
Version: 6,
Description: "Create metrics storage table",
Up: func(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS metrics (
id BIGSERIAL PRIMARY KEY,
probe_id INTEGER NOT NULL
REFERENCES probes(id)
ON DELETE CASCADE,
connection_id INTEGER NOT NULL
REFERENCES
monitored_connections(id)
ON DELETE CASCADE,
collected_at TIMESTAMP NOT NULL
DEFAULT CURRENT_TIMESTAMP,
metric_data JSONB NOT NULL,
CONSTRAINT chk_metric_data
CHECK (metric_data IS NOT NULL)
)
`)
if err != nil {
return fmt.Errorf(
"failed to create metrics table: %w",
err,
)
}
return nil
},
})
Example: Adding an Index
In the following example, the migration creates an index on the collected_at
column:
// Migration 7: Create index on metrics
sm.migrations = append(sm.migrations, Migration{
Version: 7,
Description: "Create index on metrics.collected_at",
Up: func(db *sql.DB) error {
_, err := db.Exec(`
CREATE INDEX IF NOT EXISTS
idx_metrics_collected_at
ON metrics(collected_at DESC)
`)
if err != nil {
return fmt.Errorf(
"failed to create index: %w", err,
)
}
return nil
},
})
Example: Adding a Foreign Key
In the following example, the migration adds a foreign key constraint:
// Migration 8: Add foreign key constraint
sm.migrations = append(sm.migrations, Migration{
Version: 8,
Description: "Add foreign key from metrics to probes",
Up: func(db *sql.DB) error {
var count int
err := db.QueryRow(`
SELECT COUNT(*)
FROM information_schema.table_constraints
WHERE constraint_name =
'fk_metrics_probe_id'
AND table_name = 'metrics'
`).Scan(&count)
if err != nil {
return fmt.Errorf(
"failed to check constraint: %w",
err,
)
}
if count > 0 {
return nil
}
_, err = db.Exec(`
ALTER TABLE metrics
ADD CONSTRAINT fk_metrics_probe_id
FOREIGN KEY (probe_id)
REFERENCES probes(id)
ON DELETE CASCADE
`)
if err != nil {
return fmt.Errorf(
"failed to add foreign key: %w",
err,
)
}
return nil
},
})
Example: Modifying an Existing Column
In the following example, the migration adds a new column to an existing table:
// Migration 9: Add priority column to probes
sm.migrations = append(sm.migrations, Migration{
Version: 9,
Description: "Add priority column to probes",
Up: func(db *sql.DB) error {
var count int
err := db.QueryRow(`
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_name = 'probes'
AND column_name = 'priority'
`).Scan(&count)
if err != nil {
return fmt.Errorf(
"failed to check column: %w", err,
)
}
if count > 0 {
return nil
}
_, err = db.Exec(`
ALTER TABLE probes
ADD COLUMN priority INTEGER
NOT NULL DEFAULT 5
CHECK (priority >= 1
AND priority <= 10)
`)
if err != nil {
return fmt.Errorf(
"failed to add column: %w", err,
)
}
return nil
},
})
Best Practices
Follow these best practices when designing migrations and schema changes.
Migration Design
The following guidelines apply to migration design:
- Include one logical change per migration; each migration should represent a single logical schema change.
- Never modify applied migrations; create a new migration instead.
- Make migrations idempotent; use
IF NOT EXISTS,IF EXISTS, and existence checks. - Use transactions; the SchemaManager wraps each migration in a transaction.
- Test migrations thoroughly on a development database before deploying.
Schema Design
The following guidelines apply to schema design:
- Use constraints by defining CHECK, NOT NULL, UNIQUE, and FOREIGN KEY to enforce data integrity.
- Create indexes strategically for foreign key columns, WHERE clause columns, ORDER BY columns, and JOIN conditions.
- Use appropriate data types such as SERIAL for auto-incrementing IDs, TIMESTAMP for dates, and TEXT for unlimited-length strings.
- Include
created_atandupdated_ataudit columns to track record modifications. - Plan for partitioning early for large tables such as metrics tables.
Testing
This section covers testing schema migrations.
Running Schema Tests
In the following example, the make command runs all tests:
make test
In the following example, the go test command runs only schema tests:
go test -v -run TestSchema
Test Environment
Tests require a PostgreSQL database. In the following example, the environment variable configures the test database:
export TEST_DB_CONN="host=localhost port=5432 \
user=testuser dbname=testdb sslmode=disable"
To skip database tests, set the following variable:
export SKIP_DB_TESTS=1
Writing Migration Tests
When adding a new migration, add corresponding tests that verify the following:
- The migration applies successfully without errors.
- Running the migration twice does not cause errors.
- Constraints work as expected.
- Indexes are created correctly.
In the following example, the test verifies that a migration creates a table:
func TestMigration6Metrics(t *testing.T) {
db := getTestConnection(t)
if db == nil {
return
}
defer db.Close()
cleanupTestSchema(t, db)
sm := NewSchemaManager()
if err := sm.Migrate(db); err != nil {
t.Fatalf("Failed to migrate: %v", err)
}
var count int
err := db.QueryRow(`
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_name = 'metrics'
`).Scan(&count)
if err != nil {
t.Fatalf(
"Failed to check for table: %v", err,
)
}
if count != 1 {
t.Fatal("metrics table was not created")
}
cleanupTestSchema(t, db)
}
Troubleshooting
This section covers common schema management issues.
Migration Fails to Apply
If a migration fails, follow these steps:
- Check the error message for details about the failure.
- Verify that the database connection is accessible.
- Review the migration code for logic errors.
- Check for manual schema changes that conflict with the migration.
Migration Applied but Schema Incorrect
If a migration was applied but the schema is incorrect, follow these steps:
- Check the
schema_versiontable to verify which migrations were applied. - Investigate whether the migration partially applied before failing.
- Create a new fix-up migration to correct the schema.
Rolling Back Migrations
The current system does not support automatic rollback. To roll back manually, follow these steps:
- Use SQL to undo the migration changes manually.
- Remove the migration record from
schema_version. - Consider creating a new forward migration that reverts the changes instead.
Security Considerations
Follow these security practices when writing migrations.
Secure Migration Practices
The following guidelines apply to secure migrations:
- Validate inputs if migrations use any configuration values.
- Use parameterized queries when migration logic includes dynamic values.
- Run migrations with a database user that has only the necessary privileges.
- Review all migrations for security implications before applying.
Data Protection
The following guidelines apply to data protection:
- Always back up the database before applying migrations in production.
- Test migrations on a copy of production data before applying to production.
- Ensure migrations do not inadvertently expose sensitive data.
See Also
The following resources provide additional details.
- Database Schema covers the schema structure and design.
- Probes explains how probes collect and store data.
- Architecture describes the overall system design.