Skip to content

Commit 4231d6b

Browse files
Add-disaster-recovery-automation-with-runbooks
1 parent 485d426 commit 4231d6b

12 files changed

Lines changed: 1490 additions & 219 deletions

backend/dr/DisasterRecoveryService.ts

Lines changed: 591 additions & 78 deletions
Large diffs are not rendered by default.

backend/dr/__tests__/DisasterRecoveryService.test.ts

Lines changed: 294 additions & 113 deletions
Large diffs are not rendered by default.

backend/services/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,23 @@ export type {
7171
export { BatchChargeService } from './batchChargeService';
7272
export type { BatchChargeCandidate, BatchChargeOptions, BatchChargeResult } from './batchChargeService';
7373

74-
// ── Feature Flags (Issue #TBD) ──────────────────────────────────────────────
74+
// ── Disaster Recovery ───────────────────────────────────────────────────────
75+
export { DisasterRecoveryService, disasterRecoveryService } from '../dr/DisasterRecoveryService';
76+
export type {
77+
BackupManifest,
78+
BackupEntry,
79+
VerificationResult,
80+
RecoveryResult,
81+
DrDrillResult,
82+
DrDrillSchedule,
83+
RtoMonitorEntry,
84+
RpoMonitorEntry,
85+
DrIncident,
86+
GeoRegionStatus,
87+
ConsistencyProof,
88+
} from '../dr/DisasterRecoveryService';
89+
90+
// ── Feature Flags ───────────────────────────────────────────────────────────
7591
export { BackendFeatureFlagsService, backendFeatureFlagsService } from './featureFlags';
7692
export type {
7793
FeatureFlag,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {
2+
simulateCrossServiceBackup,
3+
injectBackupInconsistency,
4+
runBackupConsistencyExperiment,
5+
} from '../experiments/backup-consistency';
6+
7+
describe('Backup Consistency Experiment', () => {
8+
it('detects consistent cross-service backup', () => {
9+
const result = simulateCrossServiceBackup(
10+
{ shared_count: '100', app_key: 'val' },
11+
{ shared_count: '100', contract_key: 'val' }
12+
);
13+
expect(result.consistent).toBe(true);
14+
expect(result.mismatches).toHaveLength(0);
15+
});
16+
17+
it('detects inconsistent shared keys', () => {
18+
const result = simulateCrossServiceBackup(
19+
{ shared_count: '100', app_key: 'val' },
20+
{ shared_count: '200', contract_key: 'val' }
21+
);
22+
expect(result.consistent).toBe(false);
23+
expect(result.mismatches).toHaveLength(1);
24+
expect(result.mismatches[0].key).toBe('shared_count');
25+
});
26+
27+
it('ignores non-shared keys', () => {
28+
const result = simulateCrossServiceBackup({ app_only: 'a' }, { contract_only: 'b' });
29+
expect(result.consistent).toBe(true);
30+
});
31+
32+
it('injects inconsistency for testing', () => {
33+
const result = injectBackupInconsistency(
34+
{ shared_x: '1' },
35+
{ shared_x: '1' },
36+
'shared_x',
37+
'10',
38+
'20'
39+
);
40+
expect(result.appData.shared_x).toBe('10');
41+
expect(result.contractData.shared_x).toBe('20');
42+
});
43+
44+
it('runBackupConsistencyExperiment passes', async () => {
45+
const result = await runBackupConsistencyExperiment();
46+
expect(result.experiment).toBe('backup-consistency');
47+
expect(result.passed).toBe(true);
48+
expect(result.recovery).toBe('inconsistency-detected');
49+
});
50+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
simulateGeoRequest,
3+
simulateRegionFailover,
4+
runGeoPartitionExperiment,
5+
} from '../experiments/geo-partition';
6+
7+
describe('Geo Partition Experiment', () => {
8+
it('simulateGeoRequest succeeds for available region', async () => {
9+
const result = await simulateGeoRequest('us-east-1', () => Promise.resolve('ok'));
10+
expect(result).toBe('ok');
11+
});
12+
13+
it('simulateGeoRequest fails for unavailable region', async () => {
14+
await expect(
15+
simulateGeoRequest('us-east-1', () => Promise.resolve('ok'), {
16+
primary: { region: 'us-east-1', available: false, latencyMs: 0 },
17+
replicas: [],
18+
})
19+
).rejects.toThrow('Region unavailable');
20+
});
21+
22+
it('simulateRegionFailover fails over to replica', async () => {
23+
const result = await simulateRegionFailover(() => Promise.resolve({ data: 'ok' }), {
24+
primary: { region: 'us-east-1', available: false, latencyMs: 0 },
25+
replicas: [{ region: 'eu-west-1', available: true, latencyMs: 10 }],
26+
});
27+
expect(result.failoverRegion).toBe('eu-west-1');
28+
expect(result.result).toEqual({ data: 'ok' });
29+
});
30+
31+
it('simulateRegionFailover throws when all regions down', async () => {
32+
await expect(
33+
simulateRegionFailover(() => Promise.resolve('ok'), {
34+
primary: { region: 'us-east-1', available: false, latencyMs: 0 },
35+
replicas: [{ region: 'eu-west-1', available: false, latencyMs: 0 }],
36+
})
37+
).rejects.toThrow('All regions unavailable');
38+
});
39+
40+
it('runGeoPartitionExperiment passes', async () => {
41+
const result = await runGeoPartitionExperiment();
42+
expect(result.experiment).toBe('geo-partition');
43+
expect(result.passed).toBe(true);
44+
expect(result.recovery).toBe('failover-to-eu-west-1');
45+
});
46+
});

chaos/__tests__/runner.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { runAllExperiments, summarize } from '../runner';
33
describe('Chaos Runner', () => {
44
it('runs all experiments and all pass', async () => {
55
const results = await runAllExperiments();
6-
expect(results).toHaveLength(3);
6+
expect(results).toHaveLength(5);
77
const failed = results.filter((r) => !r.passed);
88
expect(failed).toHaveLength(0);
99
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { ChaosResult } from './network-partition';
2+
3+
export interface ConsistencyCheckResult {
4+
serviceA: string[];
5+
serviceB: string[];
6+
mismatches: { key: string; aValue: string | null; bValue: string | null }[];
7+
consistent: boolean;
8+
}
9+
10+
export function simulateCrossServiceBackup(
11+
appData: Record<string, string>,
12+
contractData: Record<string, string>
13+
): ConsistencyCheckResult {
14+
const mismatches: { key: string; aValue: string | null; bValue: string | null }[] = [];
15+
const serviceA = Object.keys(appData);
16+
const serviceB = Object.keys(contractData);
17+
18+
const allKeys = new Set([...serviceA, ...serviceB]);
19+
for (const key of allKeys) {
20+
const aVal = appData[key] ?? null;
21+
const bVal = contractData[key] ?? null;
22+
23+
if (key.startsWith('shared_') && aVal !== bVal) {
24+
mismatches.push({ key, aValue: aVal, bValue: bVal });
25+
}
26+
}
27+
28+
return {
29+
serviceA,
30+
serviceB,
31+
mismatches,
32+
consistent: mismatches.length === 0,
33+
};
34+
}
35+
36+
export function injectBackupInconsistency(
37+
appData: Record<string, string>,
38+
contractData: Record<string, string>,
39+
inconsistencyKey: string,
40+
appValue: string,
41+
contractValue: string
42+
): { appData: Record<string, string>; contractData: Record<string, string> } {
43+
return {
44+
appData: { ...appData, [inconsistencyKey]: appValue },
45+
contractData: { ...contractData, [inconsistencyKey]: contractValue },
46+
};
47+
}
48+
49+
export async function runBackupConsistencyExperiment(): Promise<ChaosResult> {
50+
const start = Date.now();
51+
52+
const appData: Record<string, string> = {
53+
shared_user_count: '150',
54+
shared_subscription_count: '300',
55+
app_config: 'enabled',
56+
};
57+
58+
const contractData: Record<string, string> = {
59+
shared_user_count: '150',
60+
shared_subscription_count: '300',
61+
contract_state: 'active',
62+
};
63+
64+
const cleanCheck = simulateCrossServiceBackup(appData, contractData);
65+
if (!cleanCheck.consistent) {
66+
return {
67+
experiment: 'backup-consistency',
68+
passed: false,
69+
duration: Date.now() - start,
70+
error: 'Clean data reported as inconsistent',
71+
};
72+
}
73+
74+
const corrupted = injectBackupInconsistency(
75+
appData,
76+
contractData,
77+
'shared_user_count',
78+
'150',
79+
'200'
80+
);
81+
82+
const corruptedCheck = simulateCrossServiceBackup(corrupted.appData, corrupted.contractData);
83+
84+
const passed = !corruptedCheck.consistent && corruptedCheck.mismatches.length === 1;
85+
86+
return {
87+
experiment: 'backup-consistency',
88+
passed,
89+
duration: Date.now() - start,
90+
recovery: passed ? 'inconsistency-detected' : undefined,
91+
error: passed
92+
? undefined
93+
: `Expected 1 mismatch, got ${corruptedCheck.mismatches.length}, consistent=${corruptedCheck.consistent}`,
94+
};
95+
}

chaos/experiments/geo-partition.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { ChaosResult } from './network-partition';
2+
3+
export interface GeoRegionState {
4+
region: string;
5+
available: boolean;
6+
latencyMs: number;
7+
}
8+
9+
export interface GeoPartitionScenario {
10+
primary: GeoRegionState;
11+
replicas: GeoRegionState[];
12+
}
13+
14+
const DEFAULT_SCENARIO: GeoPartitionScenario = {
15+
primary: { region: 'us-east-1', available: true, latencyMs: 5 },
16+
replicas: [
17+
{ region: 'eu-west-1', available: true, latencyMs: 80 },
18+
{ region: 'ap-southeast-1', available: true, latencyMs: 150 },
19+
],
20+
};
21+
22+
export async function simulateGeoRequest<T>(
23+
region: string,
24+
fn: () => Promise<T>,
25+
scenario: GeoPartitionScenario = DEFAULT_SCENARIO
26+
): Promise<T> {
27+
const allRegions = [scenario.primary, ...scenario.replicas];
28+
const regionState = allRegions.find((r) => r.region === region);
29+
30+
if (!regionState) throw new Error(`Unknown region: ${region}`);
31+
if (!regionState.available) throw new Error(`Region unavailable: ${region}`);
32+
33+
if (regionState.latencyMs > 0) {
34+
await new Promise((r) => setTimeout(r, regionState.latencyMs));
35+
}
36+
37+
return fn();
38+
}
39+
40+
export async function simulateRegionFailover<T>(
41+
fn: () => Promise<T>,
42+
scenario: GeoPartitionScenario
43+
): Promise<{ result: T | null; failoverRegion: string; failoverDurationMs: number }> {
44+
const start = Date.now();
45+
46+
if (!scenario.primary.available) {
47+
for (const replica of scenario.replicas) {
48+
if (!replica.available) continue;
49+
const replicaStart = Date.now();
50+
try {
51+
const result = await simulateGeoRequest(replica.region, fn, scenario);
52+
return {
53+
result,
54+
failoverRegion: replica.region,
55+
failoverDurationMs: Date.now() - replicaStart,
56+
};
57+
} catch {
58+
continue;
59+
}
60+
}
61+
throw new Error('All regions unavailable');
62+
}
63+
64+
const result = await fn();
65+
return {
66+
result,
67+
failoverRegion: scenario.primary.region,
68+
failoverDurationMs: Date.now() - start,
69+
};
70+
}
71+
72+
export async function runGeoPartitionExperiment(): Promise<ChaosResult> {
73+
const start = Date.now();
74+
75+
const scenario: GeoPartitionScenario = {
76+
primary: { region: 'us-east-1', available: false, latencyMs: 0 },
77+
replicas: [
78+
{ region: 'eu-west-1', available: true, latencyMs: 80 },
79+
{ region: 'ap-southeast-1', available: false, latencyMs: 0 },
80+
],
81+
};
82+
83+
try {
84+
const { failoverRegion, failoverDurationMs } = await simulateRegionFailover(
85+
async () => ({ data: 'recovered' }),
86+
scenario
87+
);
88+
89+
const passed = failoverRegion === 'eu-west-1' && failoverDurationMs < 500;
90+
91+
return {
92+
experiment: 'geo-partition',
93+
passed,
94+
duration: Date.now() - start,
95+
recovery: `failover-to-${failoverRegion}`,
96+
error: passed ? undefined : `Failover took ${failoverDurationMs}ms or went to wrong region`,
97+
};
98+
} catch (err) {
99+
return {
100+
experiment: 'geo-partition',
101+
passed: false,
102+
duration: Date.now() - start,
103+
error: err instanceof Error ? err.message : String(err),
104+
};
105+
}
106+
}

chaos/runner.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
/**
2-
* Chaos Runner — executes all experiments and reports results.
3-
*/
4-
51
import { runNetworkPartitionExperiment } from './experiments/network-partition';
62
import { runServiceDegradationExperiment } from './experiments/service-degradation';
73
import { runFailureInjectionExperiment } from './experiments/failure-injection';
4+
import { runGeoPartitionExperiment } from './experiments/geo-partition';
5+
import { runBackupConsistencyExperiment } from './experiments/backup-consistency';
86
import type { ChaosResult } from './experiments/network-partition';
97

108
export async function runAllExperiments(): Promise<ChaosResult[]> {
119
const results = await Promise.all([
1210
runNetworkPartitionExperiment(),
1311
runServiceDegradationExperiment(),
1412
runFailureInjectionExperiment(),
13+
runGeoPartitionExperiment(),
14+
runBackupConsistencyExperiment(),
1515
]);
1616
return results;
1717
}
@@ -20,9 +20,9 @@ export function summarize(results: ChaosResult[]): void {
2020
const passed = results.filter((r) => r.passed).length;
2121
console.log(`\nChaos Engineering Results: ${passed}/${results.length} passed\n`);
2222
for (const r of results) {
23-
const status = r.passed ? '' : '';
24-
console.log(`${status} ${r.experiment} (${r.duration}ms)`);
25-
if (r.recovery) console.log(` recovery: ${r.recovery}`);
26-
if (r.error) console.log(` error: ${r.error}`);
23+
const status = r.passed ? 'PASS' : 'FAIL';
24+
console.log(`${status} ${r.experiment} (${r.duration}ms)`);
25+
if (r.recovery) console.log(` recovery: ${r.recovery}`);
26+
if (r.error) console.log(` error: ${r.error}`);
2727
}
2828
}

0 commit comments

Comments
 (0)