Async Reconciliation

Asynchronous Compaction & Drift Prevention

Background workers that reconcile the graph against the source of truth, merge duplicate nodes, compact version histories, and detect state drift. Every operation is idempotent, logged, and checksum-verified.

Compaction Worker

How compaction works

The compaction worker runs as a background cron job. Each cycle scans for compaction candidates, detects duplicate nodes, resolves conflicts, atomically replaces duplicates, and verifies checksums.

Compaction Worker Pipeline

Runs as a background cron with configurable interval and batch size.

1

Scan for Candidates

Query the entity store for nodes exceeding a configurable staleness threshold or with missing checksums. Candidates are batched (default batch size: 1000) to limit memory pressure.

candidate-query.tstypescript
async function findCompactionCandidates(
  store: EntityStore,
  opts: { batchSize: number; stalenessDays: number },
): Promise<EntityRef[]> {
  const cutoff = Date.now() - opts.stalenessDays * 86400_000;
  return store.query({
    filter: {
      anyOf: [
        { field: "updated_at", lt: cutoff },
        { field: "checksum", eq: null },
      ],
    },
    limit: opts.batchSize,
  });
}
2

Detect Duplicates

For each candidate, compute a fingerprint using canonical keys (email domain, name similarity, reply-chain analysis). Group candidates by fingerprint and flag groups with more than one node as duplicates.

duplicate-detector.tstypescript
interface Fingerprint {
  email: string;
  nameNormalized: string;
  domain: string;
}

function fingerprint(entity: Person): Fingerprint {
  return {
    email: entity.email.toLowerCase(),
    nameNormalized: entity.name
      .toLowerCase()
      .replace(/[^a-z0-9]/g, ""),
    domain: entity.domain.toLowerCase(),
  };
}

function findDuplicates(
  candidates: Person[],
): Map<string, Person[]> {
  const groups = new Map<string, Person[]>();

  for (const c of candidates) {
    const fp = fingerprint(c);
    const key = `${fp.domain}:${fp.nameNormalized}`;
    const group = groups.get(key) ?? [];
    group.push(c);
    groups.set(key, group);
  }

  // Return only groups with >1 member
  for (const [key, group] of groups) {
    if (group.length < 2) groups.delete(key);
  }
  return groups;
}
3

Resolve Conflicts

For each duplicate group, apply the configured merge strategy to produce a single canonical node. The default strategy is last-writer-wins with field-level reconciliation for arrays.

4

Atomic Replace

Replace all duplicate nodes with the canonical node in a single transaction. Update all incoming edges to point to the canonical node. Record the merge in the audit log with before/after checksums.

5

Verify Checksums

Recompute the checksum of the canonical node and persist it. If the recomputed checksum does not match the stored checksum, flag the entity for re-reconciliation with the source metadata.

Visualization

Graph State Before and After

Three person nodes (j.doe, john.d, jdoe) are detected as duplicates and merged into a single canonical node. All outgoing edges are re-pointed to the canonical identity.

Graph State Before and After Compaction

Before Compactionj.doejohn.djdoeacmeteam3 duplicate person nodes
After CompactionJohn Doej@doe.comacmeteam1 canonical node, clean edges
DuplicateCanonicalExternal
Merge Strategies

Conflict Resolution

When duplicate nodes are detected, the compaction worker applies a configurable merge strategy to produce a single canonical node.

Last-Writer-Wins (Default)

For each conflicting field, the value from the node with the most recentupdated_at timestamp is selected. Array fields use set union across all duplicates. This is the safest default since it never loses data.

Field-Level Reconciliation

Each field type uses its own reconciliation rule: scalar fields use most-recent, counter fields are summed, array fields are unioned, and reference fields (edge pointers) are merged if they point to distinct targets.

Time-Ranked Composite

Nodes are ranked by data richness (number of populated fields, edge count, recency). The richest node becomes the base; fields from lower-ranked nodes fill in gaps. This produces the most complete result but is more expensive.

Manual Review Queue

When confidence in the merge is low (e.g., conflicting names from different domains), the duplicate group is marked for manual review. The compaction worker skips these groups and logs them to the review dashboard.

Implementation

Merge Engine

The merge engine takes a group of duplicate nodes and a strategy, then produces a canonical node with a merge log explaining every field-level decision.

merge-engine.tstypescript
// Generic merge engine. Works with any entity type that has a
// checksum, version vector, and typed fields.

export type MergeStrategy = "last_writer_wins" | "field_level" | "time_ranked" | "manual_review";

export interface MergePlan {
  canonical: object;
  mergeLog: MergeEntry[];
  strategy: MergeStrategy;
}

export interface MergeEntry {
  field: string;
  resolution: "kept" | "replaced" | "merged" | "filled";
  fromValues: unknown[];
  toValue: unknown;
  reason: string;
}

export class MergeEngine {
  merge<T extends { updated_at: number; checksum?: string }>(
    duplicates: T[],
    strategy: MergeStrategy,
  ): MergePlan {
    switch (strategy) {
      case "last_writer_wins":
        return this.lastWriterWins(duplicates);
      case "field_level":
        return this.fieldLevelReconciliation(duplicates);
      case "time_ranked":
        return this.timeRankedComposite(duplicates);
      case "manual_review":
        return { canonical: duplicates[0], mergeLog: [], strategy };
      default:
        return this.lastWriterWins(duplicates);
    }
  }

  private lastWriterWins<T extends { updated_at: number }>(
    duplicates: T[],
  ): MergePlan {
    const sorted = [...duplicates].sort(
      (a, b) => b.updated_at - a.updated_at,
    );
    const canonical = { ...sorted[0] };
    const mergeLog: MergeEntry[] = [];

    // For each field, take the most recently written value;
    // for arrays, union all values.
    for (const key of Object.keys(canonical)) {
      if (key === "updated_at" || key === "checksum") continue;

      const values = sorted.map((d) => (d as Record<string, unknown>)[key]);
      const isArray = Array.isArray(values[0]);

      if (isArray) {
        const merged = [...new Set(values.flat() as unknown[])];
        (canonical as Record<string, unknown>)[key] = merged;
        mergeLog.push({
          field: key,
          resolution: "merged",
          fromValues: values,
          toValue: merged,
          reason: `Set union of ${values.length} duplicates`,
        });
      } else {
        const winner = values[0];
        mergeLog.push({
          field: key,
          resolution: values.slice(1).every((v) => v === winner) ? "kept" : "replaced",
          fromValues: values,
          toValue: winner,
          reason: `Last writer wins: selected from most recent node`,
        });
      }
    }

    return { canonical, mergeLog, strategy: "last_writer_wins" };
  }

  private fieldLevelReconciliation<T extends { updated_at: number }>(
    duplicates: T[],
  ): MergePlan {
    const base = {} as Record<string, unknown>;
    const mergeLog: MergeEntry[] = [];
    const entries = Object.keys(duplicates[0] as Record<string, unknown>)
      .filter((k) => k !== "checksum");

    for (const key of entries) {
      const values = duplicates.map((d) =>
        (d as Record<string, unknown>)[key],
      );
      const unique = [...new Set(values.map((v) => JSON.stringify(v)))].map(
        (s) => JSON.parse(s),
      );

      if (unique.length === 1) {
        base[key] = unique[0];
        continue;
      }

      // Resolve by field type
      const sample = unique[0];
      if (typeof sample === "number" && key.endsWith("_count")) {
        // Counter field: sum
        base[key] = (unique as number[]).reduce((a, b) => a + b, 0);
        mergeLog.push({
          field: key,
          resolution: "merged",
          fromValues: values,
          toValue: base[key],
          reason: `Counter: summed ${unique.length} values`,
        });
      } else if (Array.isArray(sample)) {
        // Array field: union
        base[key] = [...new Set(unique.flat() as unknown[])];
        mergeLog.push({
          field: key,
          resolution: "merged",
          fromValues: values,
          toValue: base[key],
          reason: `Array: set union of ${unique.length} sources`,
        });
      } else if (key === "updated_at") {
        // Timestamp: pick max
        base[key] = Math.max(...(unique as number[]));
      } else {
        // Scalar: pick most recent node's value
        const mostRecent = [...duplicates].sort(
          (a, b) => b.updated_at - a.updated_at,
        )[0];
        base[key] = (mostRecent as Record<string, unknown>)[key];
        mergeLog.push({
          field: key,
          resolution: "replaced",
          fromValues: values,
          toValue: base[key],
          reason: `Scalar: last writer wins`,
        });
      }
    }

    return {
      canonical: base as T,
      mergeLog,
      strategy: "field_level",
    };
  }

  private timeRankedComposite<T extends { updated_at: number }>(
    duplicates: T[],
  ): MergePlan {
    // Rank by: populated fields (desc), edge count (desc), recency (desc)
    const ranked = [...duplicates].sort((a, b) => {
      const aPopulated = Object.values(a as Record<string, unknown>)
        .filter((v) => v !== null && v !== undefined && v !== "").length;
      const bPopulated = Object.values(b as Record<string, unknown>)
        .filter((v) => v !== null && v !== undefined && v !== "").length;
      if (aPopulated !== bPopulated) return bPopulated - aPopulated;
      return b.updated_at - a.updated_at;
    });

    const base = { ...ranked[0] } as Record<string, unknown>;
    const mergeLog: MergeEntry[] = [];

    // Fill gaps from lower-ranked nodes
    for (let i = 1; i < ranked.length; i++) {
      for (const [key, value] of Object.entries(ranked[i] as Record<string, unknown>)) {
        if (key === "checksum" || key === "updated_at") continue;
        const current = base[key];
        if (current === null || current === undefined || current === "" || 
            (Array.isArray(current) && current.length === 0)) {
          base[key] = value;
          mergeLog.push({
            field: key,
            resolution: "filled",
            fromValues: [current, value],
            toValue: value,
            reason: `Gap-filled from rank-${i} node`,
          });
        }
      }
    }

    return { canonical: base as T, mergeLog, strategy: "time_ranked" };
  }
}
Snapshots

Versioned Snapshots

Every compaction cycle creates a versioned snapshot of the affected entities. Snapshots enable point-in-time recovery, audit trails, and incremental drift detection.

snapshot-manager.tstypescript
// Manages versioned snapshots of entity state. Each snapshot
// captures the full serialized state at a point in time,
// keyed by (entity_type, entity_id, version).

export interface Snapshot {
  entityType: string;
  entityId: string;
  version: number;
  state: object;
  checksum: string;
  parentVersion: number | null;
  createdAt: number;
  compactionId: string;
}

export class SnapshotManager {
  private store: SnapshotStore;
  private currentVersion = new Map<string, number>();

  constructor(store: SnapshotStore) {
    this.store = store;
  }

  async takeSnapshot(
    entityType: string,
    entity: { id: string } & object,
    compactionId: string,
  ): Promise<Snapshot> {
    const key = `${entityType}:${(entity as Record<string, string>).id}`;
    const currentVer = this.currentVersion.get(key) ?? 0;
    const version = currentVer + 1;

    const snapshot: Snapshot = {
      entityType,
      entityId: (entity as Record<string, string>).id,
      version,
      state: { ...entity },
      checksum: computeEntityChecksum(entity),
      parentVersion: currentVer > 0 ? currentVer : null,
      createdAt: Date.now(),
      compactionId,
    };

    await this.store.put(snapshot);
    this.currentVersion.set(key, version);

    return snapshot;
  }

  async restoreSnapshot(
    entityType: string,
    entityId: string,
    version: number,
  ): Promise<object | null> {
    return this.store.get(entityType, entityId, version);
  }

  async getLatestSnapshot(
    entityType: string,
    entityId: string,
  ): Promise<Snapshot | null> {
    return this.store.getLatest(entityType, entityId);
  }

  async listSnapshots(
    entityType: string,
    entityId: string,
  ): Promise<Snapshot[]> {
    return this.store.list(entityType, entityId);
  }
}

// Snapshot retention: keep all snapshots for 30 days,
// then compact to daily snapshots for 90 days, then
// weekly for 1 year.
export const SNAPSHOT_RETENTION: RetentionRule[] = [
  { maxAge: 30 * 86400_000, keepAll: true },
  { maxAge: 90 * 86400_000, keepInterval: 86400_000 },  // daily
  { maxAge: 365 * 86400_000, keepInterval: 7 * 86400_000 },  // weekly
];

export async function applyRetentionPolicy(
  manager: SnapshotManager,
  entityType: string,
  entityId: string,
): Promise<number> {
  const snapshots = await manager.listSnapshots(entityType, entityId);
  const now = Date.now();
  let deleted = 0;

  for (const snap of snapshots) {
    const age = now - snap.createdAt;
    const rule = SNAPSHOT_RETENTION.find((r) => age <= r.maxAge);

    if (!rule) {
      // Beyond all retention windows - delete
      await manager.store.delete(entityType, entityId, snap.version);
      deleted++;
    }
    // Rule-based retention is applied elsewhere
  }

  return deleted;
}

interface RetentionRule {
  maxAge: number;
  keepAll?: boolean;
  keepInterval?: number;
}

interface SnapshotStore {
  put(snapshot: Snapshot): Promise<void>;
  get(entityType: string, entityId: string, version: number): Promise<object | null>;
  getLatest(entityType: string, entityId: string): Promise<Snapshot | null>;
  list(entityType: string, entityId: string): Promise<Snapshot[]>;
  delete(entityType: string, entityId: string, version: number): Promise<void>;
}

Snapshot Lifecycle

Creation
Snapshot taken at compaction time, keyed by (type, id, version)
Active Window
All snapshots retained for first 30 days
Compression
Compressed to daily snapshots for days 31-90
Long-Term
Compressed to weekly snapshots for days 91-365
Expiration
Snapshots older than 1 year are pruned

Recovery workflow

When drift is detected, the operator can restore an entity to any previous snapshot version. The restore replays the entity's state from the snapshot and re-runs the pipeline for incoming messages that arrived after the snapshot timestamp, re-applying only the relevant mutations.

Drift Detection

Reconciliation Worker

The reconciliation worker runs as a background process that re-fetches source metadata for each entity, replays the pipeline, and compares checksums. When drift is found, it automatically re-merges and logs the event.

reconciliation-worker.tstypescript
// Full reconciliation worker. Runs as a background cron that
// iterates over entities, fetches source metadata, re-plays the
// pipeline, and verifies consistency.

export class ReconciliationWorker {
  private stores: EntityStores;
  private graphUpdater: GraphUpdater;
  private snapshotManager: SnapshotManager;

  constructor(
    stores: EntityStores,
    graphUpdater: GraphUpdater,
    snapshotManager: SnapshotManager,
  ) {
    this.stores = stores;
    this.graphUpdater = graphUpdater;
    this.snapshotManager = snapshotManager;
  }

  async reconcileEntity(
    entityType: string,
    entityId: string,
  ): Promise<ReconciliationResult> {
    // 1. Fetch the current entity state
    const current = await this.stores.getEntity(entityType, entityId);
    if (!current) {
      return { status: "not_found", entityType, entityId };
    }

    // 2. Compute current checksum
    const currentChecksum = computeEntityChecksum(current);
    const storedChecksum = (current as Record<string, unknown>).checksum as string;

    // 3. Compare checksums
    if (currentChecksum === storedChecksum) {
      return { status: "consistent", entityType, entityId };
    }

    // 4. Checksum mismatch - fetch source metadata and replay
    const sourceEvents = await this.fetchSourceEvents(entityType, entityId);
    if (sourceEvents.length === 0) {
      // No source data: update the stored checksum to match reality
      await this.stores.updateChecksum(entityType, entityId, currentChecksum);
      return {
        status: "checksum_corrected",
        entityType,
        entityId,
        previousChecksum: storedChecksum,
        newChecksum: currentChecksum,
      };
    }

    // 5. Take a snapshot before re-merging
    const compactionId = `reconcile:${entityType}:${entityId}:${Date.now()}`;
    await this.snapshotManager.takeSnapshot(entityType, current, compactionId);

    // 6. Re-merge from source events
    let merged = current;
    let changed = false;

    for (const event of sourceEvents) {
      const result = await this.graphUpdater.processEmail(event);
      if (result.status === "applied") {
        changed = true;
      }
      // Re-fetch the entity after each merge
      merged = await this.stores.getEntity(entityType, entityId);
    }

    // 7. Recompute checksum after merge
    const finalChecksum = merged
      ? computeEntityChecksum(merged)
      : currentChecksum;

    if (changed) {
      await this.stores.updateChecksum(entityType, entityId, finalChecksum);
      return {
        status: "reconciled",
        entityType,
        entityId,
        previousChecksum: storedChecksum,
        newChecksum: finalChecksum,
        eventsReplayed: sourceEvents.length,
      };
    }

    return {
      status: "checksum_updated",
      entityType,
      entityId,
      previousChecksum: storedChecksum,
      newChecksum: finalChecksum,
    };
  }

  async reconcileAll(
    entityTypes: string[],
    concurrency = 4,
  ): Promise<ReconciliationSummary> {
    const summary: ReconciliationSummary = {
      total: 0,
      consistent: 0,
      reconciled: 0,
      errors: [],
    };

    for (const entityType of entityTypes) {
      const ids = await this.stores.listEntityIds(entityType);
      summary.total += ids.length;

      // Process with limited concurrency
      const batches = this.chunk(ids, concurrency);
      for (const batch of batches) {
        const results = await Promise.allSettled(
          batch.map((id) => this.reconcileEntity(entityType, id)),
        );

        for (const result of results) {
          if (result.status === "fulfilled") {
            if (result.value.status === "consistent") {
              summary.consistent++;
            } else if (
              result.value.status === "reconciled" ||
              result.value.status === "checksum_corrected"
            ) {
              summary.reconciled++;
            }
          } else {
            summary.errors.push(result.reason?.message ?? "unknown");
          }
        }
      }
    }

    return summary;
  }

  private async fetchSourceEvents(
    entityType: string,
    entityId: string,
  ): Promise<EmailMetadataEvent[]> {
    // Query the source mail store for all events involving this entity
    const source = this.stores.getSourceStore(entityType);
    if (!source) return [];
    return source.queryByEntity(entityType, entityId);
  }

  private chunk<T>(arr: T[], size: number): T[][] {
    const chunks: T[][] = [];
    for (let i = 0; i < arr.length; i += size) {
      chunks.push(arr.slice(i, i + size));
    }
    return chunks;
  }
}

export type ReconciliationResult =
  | { status: "consistent"; entityType: string; entityId: string }
  | { status: "not_found"; entityType: string; entityId: string }
  | { status: "checksum_corrected"; entityType: string; entityId: string; previousChecksum: string; newChecksum: string }
  | { status: "reconciled"; entityType: string; entityId: string; previousChecksum: string; newChecksum: string; eventsReplayed: number }
  | { status: "checksum_updated"; entityType: string; entityId: string; previousChecksum: string; newChecksum: string }
  | { status: "error"; entityType: string; entityId: string; error: string };

interface ReconciliationSummary {
  total: number;
  consistent: number;
  reconciled: number;
  errors: string[];
}

MailGPT - Async Compaction & Drift Prevention Specification

Compaction workers, merge engine, versioned snapshots, reconciliation worker.