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:

  1. Automatic: New tables are created via Liquibase changesets

  2. Dual-write period: Both old and new schemas are populated during transition

  3. Batch backfill: Historical data is migrated in batches

  4. Validation: Built-in queries verify migration completeness

  5. 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 idx_event_entity_history

Timeline query (<500 events)

<500ms

May need LIMIT clause

Snapshot lookup by hash

<10ms

Uses unique index on content_hash

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 Integrity

-- Verify events have proper ordering
SELECT
    entity_type,
    entity_id,
    COUNT(*) AS event_count,
    MIN(occurred_at) AS first_change,
    MAX(occurred_at) AS last_change
FROM audit_event
GROUP BY entity_type, entity_id
HAVING COUNT(*) > 1
ORDER BY event_count DESC
LIMIT 10;

Snapshot Linkage

-- Verify events are linked to snapshots
SELECT
    COUNT(DISTINCT ae.id) AS events_with_snapshots,
    (SELECT COUNT(*) FROM audit_event) AS total_events
FROM audit_event ae
JOIN audit_event_snapshot aes ON ae.id = aes.event_id;

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