Phase 2 Audit Migration Guide
This guide covers the migration from Phase 1 audit schema to the Phase 2 normalized schema.
Overview
Phase 2 introduces a normalized audit schema that provides:
-
60% storage savings through snapshot deduplication
-
80-90% fewer NULL values via proper normalization
-
Field-level change tracking for granular audit queries
-
Timeline API for reconstructing entity history
-
Better performance with optimized indexes
Migration Strategy
The migration is designed to be backward compatible and low-risk:
-
Automatic: New tables are created via Liquibase changesets
-
Dual-write period: Both old and new schemas are populated during transition
-
Batch backfill: Historical data is migrated in batches
-
Validation: Built-in queries verify migration completeness
-
Rollback capable: Old tables remain until migration is validated
Pre-Migration Checklist
Before starting the migration:
-
Backup database: Create a full backup before migration
-
Check disk space: Ensure at least 50% free space (temporary increase during migration)
-
Schedule maintenance window: Plan for 2-4 hours for large datasets
-
Review current audit size:
SELECT COUNT(*) AS total_events, COUNT(DISTINCT entity_type) AS entity_types, MIN(occurred_at) AS earliest_event, MAX(occurred_at) AS latest_event FROM audit_log; -
Verify Liquibase version: Ensure Liquibase 4.31+ is installed
Migration Steps
Step 1: Apply Schema Changes
The schema changesets will run automatically via Liquibase:
# Review changesets that will be applied
./gradlew liquibaseStatus
# Apply changesets (creates new Phase 2 tables)
./gradlew liquibaseUpdate
Expected output:
CREATE TABLE audit_event ...
CREATE TABLE audit_snapshot ...
CREATE TABLE audit_event_snapshot ...
CREATE TABLE audit_change ...
Step 2: Enable Dual-Write Mode
Update application configuration to write to both schemas:
# application.yml
audit:
phase2:
enabled: true # Enable Phase 2 audit writer
dual-write: true # Write to both Phase 1 and Phase 2
Restart the application:
./gradlew bootRun
Verification: After restart, new audit events should appear in both schemas:
-- Should have matching counts for new events
SELECT COUNT(*) FROM audit_log WHERE occurred_at > NOW() - INTERVAL 1 HOUR;
SELECT COUNT(*) FROM audit_event WHERE occurred_at > NOW() - INTERVAL 1 HOUR;
Step 3: Backfill Historical Data
The migration changeset (2026-02-04-audit-phase2-migration.xml) includes batch migration logic.
Run with migration context:
# Apply migration changesets (backfill historical data)
./gradlew liquibaseUpdate -PliquibaseContexts=migration
# This will execute:
# 1. Migrate events from audit_log to audit_event
# 2. Extract and deduplicate snapshots
# 3. Link events to snapshots
# 4. Create validation views
For large datasets (>100K events), consider batch processing:
-- MySQL: Migrate in batches of 10,000 events
SET @batch_size = 10000;
SET @offset = 0;
-- Run in loop until no more rows
INSERT INTO audit_event (...)
SELECT ... FROM audit_log
WHERE id > @offset AND id <= @offset + @batch_size
...
Step 4: Validate Migration
Use the built-in validation views:
-- Check migration completeness
SELECT * FROM v_audit_migration_stats;
Expected output:
metric | count
--------------------------------|-------
Phase 1 Events | 50000
Phase 2 Events | 50000 ← Should match Phase 1
Phase 1 Payloads (old) | 40000
Phase 1 Payloads (new) | 45000
Phase 2 Unique Snapshots | 30000 ← Deduplicated (60% savings)
Phase 2 Total Snapshot Refs | 85000 ← Sum of old + new payloads
Check deduplication effectiveness:
SELECT * FROM v_audit_deduplication_stats;
Expected output:
unique_snapshots | total_references | savings_pct
-----------------|------------------|-------------
30000 | 85000 | 64.71
Verify timeline queries work:
-- Get timeline for a specific entity
SELECT ae.occurred_at, ae.operation, ae.username
FROM audit_event ae
WHERE ae.entity_type = 'AppPort' AND ae.entity_id = '1'
ORDER BY ae.occurred_at ASC;
Check field-level changes:
-- Verify changes were tracked (for new events only)
SELECT COUNT(*) FROM audit_change;
-- Note: Only events created after Phase 2 activation will have field changes
Step 5: Monitor Dual-Write Period
Run in dual-write mode for at least 1 week to ensure stability.
Monitoring queries:
-- Check for discrepancies in recent events
SELECT
(SELECT COUNT(*) FROM audit_log WHERE occurred_at > NOW() - INTERVAL 1 DAY) AS phase1_count,
(SELECT COUNT(*) FROM audit_event WHERE occurred_at > NOW() - INTERVAL 1 DAY) AS phase2_count;
-- Both counts should be identical
Performance monitoring:
-- Check timeline query performance (should be <100ms)
EXPLAIN SELECT * FROM audit_event
WHERE entity_type = 'AppPort' AND entity_id = '123'
ORDER BY occurred_at DESC;
-- Verify index usage: should use idx_event_entity_history
Step 6: Switch to Phase 2 Only
After validation period, disable dual-write:
# application.yml
audit:
phase2:
enabled: true
dual-write: false # Stop writing to Phase 1
Restart application:
./gradlew bootRun
Verification:
-- New events should only appear in Phase 2
SELECT COUNT(*) FROM audit_event WHERE occurred_at > NOW() - INTERVAL 5 MINUTE;
-- Should show new events
SELECT COUNT(*) FROM audit_log WHERE occurred_at > NOW() - INTERVAL 5 MINUTE;
-- Should show 0 (no new events)
Step 7: Archive Old Tables (Optional)
After 3-6 months of stable Phase 2 operation, optionally archive old tables:
-- Create archive tables
CREATE TABLE audit_log_archive AS SELECT * FROM audit_log;
CREATE TABLE audit_log_payload_archive AS SELECT * FROM audit_log_payload;
-- Verify archive
SELECT COUNT(*) FROM audit_log_archive;
SELECT COUNT(*) FROM audit_log;
-- Counts should match
-- Drop old tables (optional, only after verification)
-- DROP TABLE audit_log_payload;
-- DROP TABLE audit_log;
Rollback Procedure
If issues are encountered, rollback to Phase 1:
Immediate Rollback (During Dual-Write)
# application.yml
audit:
phase2:
enabled: false # Disable Phase 2
dual-write: false
Restart application. System will use Phase 1 only.
Full Rollback (After Phase 2 Switch)
# Step 1: Re-enable dual-write pointing to Phase 1
audit:
phase2:
enabled: false
dual-write: false
-- Step 2: Drop Phase 2 tables (rollback changesets)
./gradlew liquibaseRollbackCount -PliquibaseRollbackCount=10
-- Or manually:
DROP VIEW v_audit_deduplication_stats;
DROP VIEW v_audit_migration_stats;
DROP TABLE audit_event_snapshot;
DROP TABLE audit_change;
DROP TABLE audit_snapshot;
DROP TABLE audit_event;
Common Issues and Solutions
Issue: Migration Takes Too Long
Solution: Use batch processing
-- Process 10,000 events at a time
INSERT INTO audit_event (...)
SELECT ... FROM audit_log
WHERE id BETWEEN 1 AND 10000;
INSERT INTO audit_event (...)
SELECT ... FROM audit_log
WHERE id BETWEEN 10001 AND 20000;
-- Repeat...
Issue: Duplicate Key Errors
Symptom:
ERROR: Duplicate entry 'abc123...' for key 'content_hash'
Cause: Re-running migration without cleanup
Solution:
-- Clear partial migration data
DELETE FROM audit_event_snapshot WHERE event_id IN (SELECT id FROM audit_log);
DELETE FROM audit_snapshot WHERE content_hash IN (
SELECT old_payload_hash FROM audit_log_payload
UNION
SELECT new_payload_hash FROM audit_log_payload
);
DELETE FROM audit_event WHERE id IN (SELECT id FROM audit_log);
-- Re-run migration
./gradlew liquibaseUpdate -PliquibaseContexts=migration
Issue: Timeline Queries Are Slow
Symptom: Timeline queries take >1 second
Diagnosis:
EXPLAIN SELECT * FROM audit_event
WHERE entity_type = 'AppPort' AND entity_id = '123'
ORDER BY occurred_at DESC;
Solution: Verify indexes exist:
SHOW INDEXES FROM audit_event WHERE Key_name = 'idx_event_entity_history';
If missing:
CREATE INDEX idx_event_entity_history
ON audit_event (entity_type, entity_id, occurred_at DESC);
Issue: Deduplication Savings <50%
Symptom: v_audit_deduplication_stats shows savings_pct < 50%
Diagnosis:
-- Check if snapshots are truly different
SELECT content_hash, ref_count
FROM audit_snapshot
ORDER BY ref_count DESC
LIMIT 10;
Explanation: Low savings may occur if:
-
Entities are frequently modified with unique values
-
Timestamps or IDs are included in snapshots
-
Few repeat configurations
Acceptable: Even 30-40% savings is significant. The main benefit is also structural (field tracking, timeline API).
Performance Benchmarks
Expected performance after migration:
| Operation | Target | Notes |
|---|---|---|
Timeline query (<100 events) |
<100ms |
Uses |
Timeline query (<500 events) |
<500ms |
May need |
Snapshot lookup by hash |
<10ms |
Uses unique index on |
Field history query |
<200ms |
Filtered by entity type and ID |
Deduplication ratio |
>50% |
Depends on workload |
Write overhead (dual-write) |
+20% |
Temporary during migration |
Validation Queries Reference
Event Count Comparison
SELECT 'Phase 1' AS phase, COUNT(*) AS events FROM audit_log
UNION ALL
SELECT 'Phase 2' AS phase, COUNT(*) AS events FROM audit_event;
Snapshot Deduplication
SELECT
COUNT(*) AS unique_snapshots,
SUM(ref_count) AS total_references,
ROUND(100.0 * (1 - COUNT(*) / SUM(ref_count)), 2) AS savings_pct
FROM audit_snapshot;
Timeline API Examples
After migration, use the Timeline Service:
@Autowired
private AuditTimelineService timelineService;
// Get complete entity history
EntityTimelineResponse timeline =
timelineService.getEntityTimeline("AppPort", "123");
// Get recent activity for dashboard
List<TimelineEntryDto> recent =
timelineService.getRecentActivityByType("AppPort");
// Get user audit trail
List<TimelineEntryDto> userActivity =
timelineService.getUserActivity("admin@example.com");
// Get transaction group
List<TimelineEntryDto> transactionOps =
timelineService.getTransactionEvents("req-abc123");
Support
For migration assistance:
-
Documentation:
docs/modules/ROOT/pages/audit-best-practices.adoc -
Schema Details:
db/changelog/2025-02-03-audit-phase2-schema.xml -
Migration Script:
db/changelog/2025-02-04-audit-phase2-migration.xml -
Tests:
src/test/java/…/audit/it/AuditPhase2SchemaMySqlIT.java
Summary
Timeline for typical deployment:
| Phase | Duration | Description |
|---|---|---|
Preparation |
1 week |
Review, backup, schedule maintenance |
Schema deployment |
<1 hour |
Apply Liquibase changesets |
Dual-write period |
1-2 weeks |
Validate new system in production |
Historical migration |
2-4 hours |
Backfill old data (can run during business hours) |
Validation |
1 week |
Monitor, verify, optimize |
Phase 2 only |
Ongoing |
Disable Phase 1, monitor performance |
Archive |
3-6 months later |
Archive/drop old tables |
Total: 4-6 weeks from start to full Phase 2 operation (with old tables archived).
Success Criteria:
✅ All Phase 1 events migrated (100% count match) ✅ Deduplication savings ≥40% ✅ Timeline queries <100ms for typical entities ✅ Field changes tracked for new events ✅ No performance degradation ✅ Zero data loss