Skip to content

Commit bd9b89f

Browse files
committed
docs: implement schema mismatch handling with periodic recompute logic
1 parent fbd926b commit bd9b89f

1 file changed

Lines changed: 147 additions & 36 deletions

File tree

docs/docs/design/schema-versioning.md

Lines changed: 147 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,10 @@ To avoid expensive per-event validation, the schema hash is computed once and ca
271271

272272
// SchemaCache holds the precomputed schema hash for fast validation
273273
type SchemaCache struct {
274-
mu sync.RWMutex
275-
schemaHash string
274+
mu sync.RWMutex
275+
schemaHash string
276+
schemaManager *SchemaManager
277+
tables []string
276278
}
277279

278280
// Initialize computes and caches the schema hash for watched tables
@@ -285,6 +287,8 @@ func (sc *SchemaCache) Initialize(ctx context.Context, sm *SchemaManager, tables
285287
return fmt.Errorf("computing schema hash: %w", err)
286288
}
287289
sc.schemaHash = hash
290+
sc.schemaManager = sm
291+
sc.tables = tables
288292
return nil
289293
}
290294

@@ -295,11 +299,18 @@ func (sc *SchemaCache) GetSchemaHash() string {
295299
return sc.schemaHash
296300
}
297301

298-
// Invalidate clears the cache, forcing recomputation on next access
299-
func (sc *SchemaCache) Invalidate() {
302+
// Recompute recalculates the schema hash from the database
303+
// Called during pause state to detect if local DDL has been applied
304+
func (sc *SchemaCache) Recompute(ctx context.Context) (string, error) {
300305
sc.mu.Lock()
301306
defer sc.mu.Unlock()
302-
sc.schemaHash = ""
307+
308+
hash, err := sc.schemaManager.ComputeSchemaHash(ctx, sc.tables)
309+
if err != nil {
310+
return "", fmt.Errorf("recomputing schema hash: %w", err)
311+
}
312+
sc.schemaHash = hash
313+
return hash, nil
303314
}
304315
```
305316

@@ -315,16 +326,12 @@ func (r *Replicator) validateAndApplyEvent(event *ChangeLogEvent, msg *nats.Msg)
315326
if event.SchemaHash != "" {
316327
localHash := r.schemaCache.GetSchemaHash()
317328
if event.SchemaHash != localHash {
318-
log.Warn().
319-
Str("event_hash", event.SchemaHash[:8]).
320-
Str("local_hash", localHash[:8]).
321-
Msg("Schema mismatch, pausing replication")
322-
msg.NakWithDelay(30 * time.Second)
323-
return nil
329+
return r.handleSchemaMismatch(event, msg)
324330
}
325331
}
326332

327333
// Hashes match (or no hash in event) - apply directly
334+
r.resetMismatchState()
328335
return r.db.ReplicateRow(event)
329336
}
330337
```
@@ -333,22 +340,110 @@ func (r *Replicator) validateAndApplyEvent(event *ChangeLogEvent, msg *nats.Msg)
333340

334341
| Operation | Cost | When |
335342
|-----------|------|------|
336-
| Hash computation | O(tables + columns) + PRAGMA calls | Once at startup, on `-cleanup`, or schema change detection |
343+
| Hash computation | O(tables + columns) + PRAGMA calls | On startup, or during pause recompute interval |
337344
| Per-event validation | O(1) string comparison | Every incoming event |
338345
| Cache lookup | O(1) string read with RLock | Every incoming event |
339346

340-
**Cache Invalidation:**
347+
**Cache Recomputation:**
341348

342-
The cache is invalidated and recomputed:
349+
The cache is computed:
343350
- On HarmonyLite startup
344351
- When running `harmonylite -cleanup`
345-
- When a schema change is detected (future: via SQLite update hook on `sqlite_master`)
352+
- Periodically during schema mismatch pause state (see Section 5)
353+
354+
### 5. Schema Mismatch Handling (Pause with Periodic Recompute)
355+
356+
When a schema mismatch is detected (hash comparison fails), replication pauses for that shard by NAK-ing the message with a delay. The sequence map is not advanced, so ordering is preserved.
357+
358+
**Key Behavior:** During the pause state, HarmonyLite periodically recomputes the local schema hash to detect if DDL has been applied locally. Once schemas match, replication resumes automatically without requiring a restart.
359+
360+
```go
361+
// logstream/replicator.go
362+
363+
type Replicator struct {
364+
// ... existing fields
365+
schemaCache *SchemaCache
366+
schemaMismatchAt time.Time // When mismatch first detected
367+
lastRecomputeAt time.Time // Last time we recomputed during pause
368+
}
369+
370+
const (
371+
schemaNakDelay = 30 * time.Second
372+
schemaRecomputeInterval = 5 * time.Minute
373+
)
374+
375+
func (r *Replicator) handleSchemaMismatch(event *ChangeLogEvent, msg *nats.Msg) error {
376+
now := time.Now()
377+
378+
if r.schemaMismatchAt.IsZero() {
379+
// First mismatch - record timestamp, recompute immediately
380+
r.schemaMismatchAt = now
381+
r.lastRecomputeAt = now
382+
383+
newHash, err := r.schemaCache.Recompute(context.Background())
384+
if err == nil && event.SchemaHash == newHash {
385+
// Schema matches after recompute (e.g., DDL applied before startup)
386+
log.Info().Msg("Schema matches after initial recompute, applying event")
387+
r.resetMismatchState()
388+
return r.db.ReplicateRow(event)
389+
}
390+
391+
log.Warn().
392+
Str("event_hash", event.SchemaHash[:8]).
393+
Str("local_hash", newHash[:8]).
394+
Msg("Schema mismatch detected, pausing replication")
395+
396+
} else if now.Sub(r.lastRecomputeAt) >= schemaRecomputeInterval {
397+
// We've been paused for a while - recompute to check if DDL was applied
398+
r.lastRecomputeAt = now
399+
400+
newHash, err := r.schemaCache.Recompute(context.Background())
401+
if err == nil && event.SchemaHash == newHash {
402+
// Schema now matches after local DDL was applied
403+
log.Info().
404+
Dur("paused_for", now.Sub(r.schemaMismatchAt)).
405+
Msg("Schema now matches after recompute, resuming replication")
406+
r.resetMismatchState()
407+
return r.db.ReplicateRow(event)
408+
}
409+
410+
log.Warn().
411+
Str("event_hash", event.SchemaHash[:8]).
412+
Str("local_hash", newHash[:8]).
413+
Dur("paused_for", now.Sub(r.schemaMismatchAt)).
414+
Msg("Schema still mismatched after recompute")
415+
}
346416

347-
### 5. Schema Mismatch Handling (Pause Policy)
417+
// Still mismatched - NAK and wait
418+
msg.NakWithDelay(schemaNakDelay)
419+
return nil
420+
}
421+
422+
func (r *Replicator) resetMismatchState() {
423+
r.schemaMismatchAt = time.Time{}
424+
r.lastRecomputeAt = time.Time{}
425+
}
426+
```
427+
428+
**Behavior Summary:**
348429

349-
When a schema mismatch is detected (hash comparison fails), replication pauses for that shard by NAK-ing the message with a delay. The sequence map is not advanced, so ordering is preserved. Once the local schema is updated, NATS redelivers from the same sequence and replication resumes.
430+
| State | Action |
431+
|-------|--------|
432+
| First mismatch | Recompute hash immediately, NAK if still mismatched |
433+
| Subsequent NAKs within 5 min | Just NAK (no recompute) |
434+
| After 5 min pause | Recompute hash, check again |
435+
| Schema matches after recompute | Resume replication immediately |
436+
| Schema still mismatched | Log warning with pause duration, continue waiting |
350437

351-
**Behavior:** When an incoming event's `SchemaHash` doesn't match the cached local hash, the consumer logs a warning, NAKs with a delay, and does not apply the event.
438+
**Self-Healing After DDL:**
439+
440+
Once DDL is applied locally (e.g., `ALTER TABLE users ADD COLUMN email TEXT`), the next recompute cycle will detect the schema change and resume replication automatically. No restart or manual intervention is required.
441+
442+
**Performance During Pause:**
443+
444+
- NAK redelivery: every 30 seconds
445+
- Hash recomputation: every 5 minutes (not every NAK cycle)
446+
- This minimizes database introspection overhead during prolonged migration windows
352447

353448
### 6. Schema Registry via NATS KV
354449

@@ -504,12 +599,15 @@ harmonylite schema status --cluster
504599
- [ ] Ensure backward compatibility with old events (CBOR `omitempty`)
505600

506601
### Phase 3: Validation and Pause-on-Mismatch
602+
- [ ] Add `schemaMismatchAt` and `lastRecomputeAt` fields to `Replicator`
603+
- [ ] Implement `handleSchemaMismatch()` with periodic recompute logic
507604
- [ ] Add hash comparison in replication hot path (O(1) string comparison)
508605
- [ ] NAK with delay when schema hash mismatches (pause replication)
606+
- [ ] Recompute hash on first mismatch and every 5 minutes during pause
607+
- [ ] Auto-resume when schema matches after recompute
509608
- [ ] Create `__harmonylite__dead_letter_events` table
510609
- [ ] Implement dead-letter capture for apply failures
511-
- [ ] Add schema mismatch metrics (Prometheus counters)
512-
- [ ] Add dead-letter events gauge metric
610+
- [ ] Add `harmonylite_schema_mismatch_paused` gauge metric
513611

514612
### Phase 4: Cluster Visibility
515613
- [ ] Create NATS KV bucket `harmonylite-schema-registry`
@@ -520,9 +618,14 @@ harmonylite schema status --cluster
520618

521619
---
522620

523-
## Configuration Reference
621+
## Constants
524622

525-
No additional configuration required in the initial version.
623+
The following constants are used:
624+
625+
| Constant | Value | Description |
626+
|----------|-------|-------------|
627+
| `schemaRecomputeInterval` | `5m` | How often to recompute schema hash during pause state |
628+
| `schemaNakDelay` | `30s` | Delay before NATS redelivers a NAK'd message |
526629

527630
---
528631

@@ -534,13 +637,12 @@ No additional configuration required in the initial version.
534637
# Schema hash on this node (for alerting on changes)
535638
harmonylite_schema_hash_info{node_id="1", hash="a1b2c3d4"} 1
536639
537-
# Events paused due to schema mismatch
538-
harmonylite_schema_mismatch_events_total 42
539-
540-
# Dead-letter events (failed to apply)
541-
harmonylite_dead_letter_events_total{table="users"} 2
640+
# Replication paused due to schema mismatch (1 = paused, 0 = normal)
641+
harmonylite_schema_mismatch_paused 1
542642
```
543643

644+
The `harmonylite_schema_mismatch_paused` gauge is the primary metric for troubleshooting. When set to 1, check logs for hash details and apply DDL to the local node.
645+
544646
### Health Check Extension
545647

546648
Extend existing health check endpoint:
@@ -552,8 +654,7 @@ Extend existing health check endpoint:
552654
"schema": {
553655
"status": "warning",
554656
"schema_hash": "a1b2c3d4e5f6",
555-
"cluster_consistent": false,
556-
"mismatched_nodes": [3]
657+
"paused": true
557658
}
558659
}
559660
}
@@ -565,24 +666,34 @@ Extend existing health check endpoint:
565666

566667
### Performing Schema Migrations
567668

568-
Schema migrations are performed manually on each node. Incompatible events pause replication until schemas converge.
669+
Schema migrations are performed manually on each node. Incompatible events pause replication until schemas converge. **No restart is required** - HarmonyLite automatically detects schema changes during the pause state.
569670

570671
```bash
571672
# 1. Apply DDL on Node 1
572673
sqlite3 mydb.db "ALTER TABLE users ADD COLUMN email TEXT"
573674

574-
# 2. Restart HarmonyLite on Node 1 (schema hash is recalculated)
575-
systemctl restart harmonylite
576-
# Or run -cleanup to update schema state without full restart:
577-
harmonylite -cleanup -db mydb.db
675+
# 2. HarmonyLite detects the schema change automatically (within 5 minutes)
676+
# No restart required! Replication resumes once schema matches.
578677

579678
# 3. Repeat for other nodes
580679
# During migration window, nodes with older schemas will pause replication
581680

582-
# 4. After all nodes are migrated and restarted, replication resumes automatically
681+
# 4. After all nodes have the new schema, replication resumes automatically
682+
```
683+
684+
**Optional: Force Immediate Detection**
685+
686+
If you don't want to wait for the 5-minute recompute interval:
687+
688+
```bash
689+
# Option A: Restart HarmonyLite (schema hash computed on startup)
690+
systemctl restart harmonylite
691+
692+
# Option B: Run cleanup command
693+
harmonylite -cleanup -db mydb.db
583694
```
584695

585-
**Note:** During the migration window, nodes with older schemas will pause replication and NATS will redeliver once schemas converge. Events that fail to apply (e.g., constraint violations) can be moved to the dead-letter table for manual inspection.
696+
**Note:** During the migration window, nodes with older schemas will pause replication and NATS will redeliver once schemas converge. Events that fail to apply (e.g., constraint violations) are moved to the dead-letter table for manual inspection.
586697

587698
---
588699

0 commit comments

Comments
 (0)