docs: improve schema initialization plan with verification
- Fix regex pattern to handle both IF NOT EXISTS and legacy CREATE TABLE formats - Add 30-second lock timeout to prevent indefinite hangs - Add function verification (6 critical functions checked) - Document 8 ALTER TABLE statements already idempotent - Document 6 CREATE FUNCTION statements use OR REPLACE - Add time import for timeout support - Update verification checklist with new requirements - Update log messages to show table and function counts
This commit is contained in:
+104
-14
@@ -63,12 +63,15 @@
|
|||||||
**Decision:** Line-by-line stream scanning with regex pattern matching.
|
**Decision:** Line-by-line stream scanning with regex pattern matching.
|
||||||
|
|
||||||
**Rationale:**
|
**Rationale:**
|
||||||
- **Performance:** 2x faster for 959-line file (50ms vs 100ms)
|
- **Performance:** 2x faster for 960-line file (50ms vs 100ms)
|
||||||
- **Memory:** Only current line in memory at once
|
- **Memory:** Only current line in memory at once
|
||||||
- **Maintainability:** Simple `bufio.Scanner` pattern
|
- **Maintainability:** Simple `bufio.Scanner` pattern
|
||||||
- **Debugging:** Can log which line contains which table
|
- **Debugging:** Can log which line contains which table
|
||||||
|
- **Defensive:** Handles both `IF NOT EXISTS` and legacy table statements
|
||||||
|
|
||||||
**Pattern:** `regexp.MustCompile(`CREATE TABLE IF NOT EXISTS (?:\w+\.)?(\w+)`)`
|
**Pattern:** `regexp.MustCompile(`CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?(\w+)`)`
|
||||||
|
|
||||||
|
**Note:** Pattern matches both CREATE TABLE formats to support Phase 1 transition and prevent silent skips if tables lack IF NOT EXISTS clause.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -111,6 +114,11 @@ database/
|
|||||||
# Inserts needing ON CONFLICT (2):
|
# Inserts needing ON CONFLICT (2):
|
||||||
# - Line 14: library_types (name)
|
# - Line 14: library_types (name)
|
||||||
# - Line 44: system_settings (setting_key)
|
# - Line 44: system_settings (setting_key)
|
||||||
|
|
||||||
|
# Already idempotent (no changes needed):
|
||||||
|
# - 8 ALTER TABLE statements (all have IF NOT EXISTS)
|
||||||
|
# - 6 CREATE FUNCTION statements (all use OR REPLACE)
|
||||||
|
# - 1 INSERT statement (system_config has ON CONFLICT)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Step 1.2: Verify Schema Syntax
|
#### Step 1.2: Verify Schema Syntax
|
||||||
@@ -144,6 +152,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "embed" // Used for go:embed
|
_ "embed" // Used for go:embed
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -171,9 +180,10 @@ func generateLockID(key string) int64 {
|
|||||||
|
|
||||||
// parseTableNames extracts all table names from CREATE TABLE statements
|
// parseTableNames extracts all table names from CREATE TABLE statements
|
||||||
// Uses stream scanning for performance and memory efficiency
|
// Uses stream scanning for performance and memory efficiency
|
||||||
|
// Handles both "CREATE TABLE" and "CREATE TABLE IF NOT EXISTS" formats
|
||||||
func parseTableNames() ([]string, error) {
|
func parseTableNames() ([]string, error) {
|
||||||
scanner := bufio.NewScanner(strings.NewReader(SchemaFile))
|
scanner := bufio.NewScanner(strings.NewReader(SchemaFile))
|
||||||
pattern := regexp.MustCompile(`CREATE TABLE IF NOT EXISTS (?:\w+\.)?(\w+)`)
|
pattern := regexp.MustCompile(`CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?(\w+)`)
|
||||||
|
|
||||||
tables := make(map[string]bool)
|
tables := make(map[string]bool)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
@@ -239,6 +249,47 @@ func verifyTables(ctx context.Context, db *pgxpool.Pool, expectedTables []string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyFunctions checks all expected functions exist in database
|
||||||
|
func verifyFunctions(ctx context.Context, db *pgxpool.Pool, expectedFunctions []string) error {
|
||||||
|
rows, err := db.Query(ctx, `
|
||||||
|
SELECT routine_name
|
||||||
|
FROM information_schema.routines
|
||||||
|
WHERE routine_schema = 'public'
|
||||||
|
AND routine_type = 'FUNCTION'
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query existing functions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
existingFunctions := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var functionName string
|
||||||
|
if err := rows.Scan(&functionName); err != nil {
|
||||||
|
return fmt.Errorf("failed to scan function name: %w", err)
|
||||||
|
}
|
||||||
|
existingFunctions[functionName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("error iterating functions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all expected functions exist
|
||||||
|
var missing []string
|
||||||
|
for _, expected := range expectedFunctions {
|
||||||
|
if !existingFunctions[expected] {
|
||||||
|
missing = append(missing, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("missing functions: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// executeSchema runs the entire schema.sql in a single transaction
|
// executeSchema runs the entire schema.sql in a single transaction
|
||||||
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||||
tx, err := db.Begin(ctx)
|
tx, err := db.Begin(ctx)
|
||||||
@@ -271,20 +322,23 @@ func Initialize(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
}
|
}
|
||||||
defer conn.Release()
|
defer conn.Release()
|
||||||
|
|
||||||
// Get advisory lock (blocks other instances)
|
// Get advisory lock with timeout (prevents indefinite hangs)
|
||||||
log.Printf("DEBUG: Attempting to acquire advisory lock %d...", schemaInitLockID)
|
log.Printf("DEBUG: Attempting to acquire advisory lock %d...", schemaInitLockID)
|
||||||
|
lockCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
var lockAcquired bool
|
var lockAcquired bool
|
||||||
err = conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", schemaInitLockID).Scan(&lockAcquired)
|
err = conn.QueryRow(lockCtx, "SELECT pg_try_advisory_lock($1)", schemaInitLockID).Scan(&lockAcquired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to acquire advisory lock: %w", err)
|
return fmt.Errorf("failed to acquire advisory lock: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !lockAcquired {
|
if !lockAcquired {
|
||||||
log.Println("⏳ Another instance is initializing schema, waiting...")
|
log.Println("⏳ Another instance is initializing schema, waiting...")
|
||||||
// Use pg_advisory_lock instead (blocks until available)
|
// Use pg_advisory_lock instead (blocks until available or timeout)
|
||||||
_, err = conn.Exec(ctx, "SELECT pg_advisory_lock($1)", schemaInitLockID)
|
_, err = conn.Exec(lockCtx, "SELECT pg_advisory_lock($1)", schemaInitLockID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to wait for advisory lock: %w", err)
|
return fmt.Errorf("timeout waiting for advisory lock: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Println("✅ Advisory lock acquired")
|
log.Println("✅ Advisory lock acquired")
|
||||||
@@ -307,6 +361,16 @@ func Initialize(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
}
|
}
|
||||||
log.Printf("DEBUG: Found %d expected tables in schema.sql", len(expectedTables))
|
log.Printf("DEBUG: Found %d expected tables in schema.sql", len(expectedTables))
|
||||||
|
|
||||||
|
// Critical functions that must exist for app to function
|
||||||
|
criticalFunctions := []string{
|
||||||
|
"update_updated_at_column",
|
||||||
|
"detect_format_group",
|
||||||
|
"convert_progress",
|
||||||
|
"detect_conflict",
|
||||||
|
"merge_progress",
|
||||||
|
"bulk_update_progress_from_koreader",
|
||||||
|
}
|
||||||
|
|
||||||
// Execute schema in a single transaction
|
// Execute schema in a single transaction
|
||||||
log.Println("🔧 Executing schema.sql in transaction...")
|
log.Println("🔧 Executing schema.sql in transaction...")
|
||||||
err = executeSchema(ctx, db)
|
err = executeSchema(ctx, db)
|
||||||
@@ -325,7 +389,17 @@ func Initialize(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
}
|
}
|
||||||
log.Println("✅ All expected tables verified")
|
log.Println("✅ All expected tables verified")
|
||||||
|
|
||||||
log.Printf("✅ Database schema initialization complete (%d tables verified)", len(expectedTables))
|
// Verify critical functions exist
|
||||||
|
log.Println("🔍 Verifying critical functions exist...")
|
||||||
|
err = verifyFunctions(ctx, db, criticalFunctions)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR: Function verification failed: %v", err)
|
||||||
|
return fmt.Errorf("function verification failed: %w", err)
|
||||||
|
}
|
||||||
|
log.Println("✅ All critical functions verified")
|
||||||
|
|
||||||
|
log.Printf("✅ Database schema initialization complete (%d tables, %d functions verified)",
|
||||||
|
len(expectedTables), len(criticalFunctions))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -389,18 +463,24 @@ func main() {
|
|||||||
- [ ] Converted 18 CREATE TABLE → CREATE TABLE IF NOT EXISTS (27 total)
|
- [ ] Converted 18 CREATE TABLE → CREATE TABLE IF NOT EXISTS (27 total)
|
||||||
- [ ] Converted 62 CREATE INDEX → CREATE INDEX IF NOT EXISTS (82 total)
|
- [ ] Converted 62 CREATE INDEX → CREATE INDEX IF NOT EXISTS (82 total)
|
||||||
- [ ] Added ON CONFLICT to 2 INSERT statements (3 total)
|
- [ ] Added ON CONFLICT to 2 INSERT statements (3 total)
|
||||||
|
- [ ] Verified 8 ALTER TABLE statements have IF NOT EXISTS (already done)
|
||||||
|
- [ ] Verified 6 CREATE FUNCTION statements use OR REPLACE (already done)
|
||||||
- [ ] Verified schema.sql syntax is valid
|
- [ ] Verified schema.sql syntax is valid
|
||||||
- [ ] Confirmed single schema.sql exists as source of truth
|
- [ ] Confirmed single schema.sql exists as source of truth
|
||||||
|
|
||||||
**Phase 2 Verification:**
|
**Phase 2 Verification:**
|
||||||
- [ ] `internal/database/schema.go` compiles without errors
|
- [ ] `internal/database/schema.go` compiles without errors
|
||||||
- [ ] Single file with all schema initialization functions
|
- [ ] Single file with all schema initialization functions
|
||||||
- [ ] Imports only what's used (bufio, embed, pgx v5)
|
- [ ] Imports only what's used (bufio, context, embed, fmt, log, regexp, strings, time, pgx v5)
|
||||||
- [ ] Uses *pgxpool.Pool concrete type
|
- [ ] Uses *pgxpool.Pool concrete type
|
||||||
- [ ] Pre-computed constant for lock ID (7804706162000639061)
|
- [ ] Pre-computed constant for lock ID (7804706162000639061)
|
||||||
- [ ] Stream scanning implementation for performance
|
- [ ] Stream scanning implementation for performance
|
||||||
|
- [ ] Regex pattern handles both IF NOT EXISTS and legacy table formats
|
||||||
|
- [ ] Lock acquisition has 30-second timeout to prevent indefinite hangs
|
||||||
- [ ] Successfully embeds existing schema.sql file
|
- [ ] Successfully embeds existing schema.sql file
|
||||||
- [ ] No schema file duplication (single source of truth)
|
- [ ] No schema file duplication (single source of truth)
|
||||||
|
- [ ] Verifies critical functions exist, not just tables
|
||||||
|
- [ ] Provides clear error messages for missing schema components
|
||||||
|
|
||||||
**Phase 3 Verification:**
|
**Phase 3 Verification:**
|
||||||
- [ ] `cmd/server/main.go` compiles without errors
|
- [ ] `cmd/server/main.go` compiles without errors
|
||||||
@@ -429,11 +509,12 @@ func main() {
|
|||||||
### What Will Work:
|
### What Will Work:
|
||||||
✅ **App starts successfully** on fresh database
|
✅ **App starts successfully** on fresh database
|
||||||
✅ **App starts successfully** on existing database
|
✅ **App starts successfully** on existing database
|
||||||
✅ **Multiple instances can start** simultaneously with advisory locks
|
✅ **Multiple instances can start** simultaneously with advisory locks (30s timeout)
|
||||||
✅ **TestKoboInitialization and all tests pass**
|
✅ **TestKoboInitialization and all tests pass**
|
||||||
✅ **Clear log messages** show schema initialization
|
✅ **Clear log messages** show schema initialization with table/function counts
|
||||||
✅ **Production-safe** with no race conditions
|
✅ **Production-safe** with no race conditions
|
||||||
✅ **Self-healing** from partial/corrupted state
|
✅ **Self-healing** from partial/corrupted state (missing tables or functions)
|
||||||
|
✅ **Better error messages** indicate which schema components are missing
|
||||||
|
|
||||||
### What Will NOT Change:
|
### What Will NOT Change:
|
||||||
✅ **No changes to API endpoints**
|
✅ **No changes to API endpoints**
|
||||||
@@ -448,6 +529,7 @@ func main() {
|
|||||||
- **HTTP request handling:** No change
|
- **HTTP request handling:** No change
|
||||||
- **Database queries:** No change
|
- **Database queries:** No change
|
||||||
- **Memory usage:** Minimal increase (schema parsing optimization)
|
- **Memory usage:** Minimal increase (schema parsing optimization)
|
||||||
|
- **Lock timeout:** 30-second safety prevents indefinite hangs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -482,7 +564,7 @@ The app will initialize the database automatically on first startup.
|
|||||||
|
|
||||||
**Monitor logs for:**
|
**Monitor logs for:**
|
||||||
```
|
```
|
||||||
✅ Database schema initialized and verified (27 tables verified)
|
✅ Database schema initialization complete (27 tables, 6 functions verified)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Subsequent Deployments
|
### Subsequent Deployments
|
||||||
@@ -515,6 +597,14 @@ podman logs bookhoard_app | grep -i schema
|
|||||||
podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt"
|
podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verify functions exist:
|
||||||
|
```bash
|
||||||
|
podman exec bookhoard_db psql -U postgres -d bookhoard -c "
|
||||||
|
SELECT routine_name FROM information_schema.routines
|
||||||
|
WHERE routine_schema = 'public' AND routine_type = 'FUNCTION';
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
### Check advisory locks:
|
### Check advisory locks:
|
||||||
```bash
|
```bash
|
||||||
podman exec bookhoard_db psql -U postgres -d bookhoard -c "
|
podman exec bookhoard_db psql -U postgres -d bookhoard -c "
|
||||||
|
|||||||
Reference in New Issue
Block a user