Real time · docs/sync/README.md

BlueTusk Sync

Versioned destination synchronization, quarantine, reconciliation, and rebuilds.

18 min read3,860 wordsSource synchronized
View source on GitHub
ON THIS PAGE

BlueTusk Sync

BlueTusk Sync materialises transaction-preserving Streams deliveries into external destinations. It consumes BlueTusk.Streams only; it never reaches logical-replication wire messages.

Release compatibility is specified in the public API compatibility and durable format compatibility contracts. The release-endurance guide defines the mandatory 24-hour four-connector gate.

The core pipeline owns the provisioning, snapshotting, catching-up, running, paused, rebuilding, reconciling, faulted, and stopped states. A source transaction is transformed and offered to a destination as one immutable batch. The Streams delivery is acknowledged only after the destination returns the exact commit-end position as durably handled. Duplicate delivery is safe when a destination reports the same position as already applied.

The delivery guarantee

BlueTusk guarantees that an acknowledged source transaction is never skipped. If a worker stops after writing a destination but before saving source progress, the last unconfirmed transaction is delivered again with the same source, transaction, commit-position, and change identities. Every official Sync connector provides a tested replay-safety mechanism, but the durable boundary depends on the destination. For NATS, the downstream consumer must retain the stable identity beyond JetStream’s configured deduplication window:

Destination Guarantee What happens during recovery
PostgreSQL Atomic state and checkpoint The complete mutation and checkpoint commit in one PostgreSQL transaction. Recovery sees both or neither.
Redis Atomic state and checkpoint One same-slot Lua operation validates and writes the complete mutation plus checkpoint. Recovery sees both or neither.
OpenSearch Replay-safe materialisation A failed or ambiguous bulk is replayed with the same external versions. Older source versions cannot replace newer materialized state, and the checkpoint advances only after every item succeeds.
NATS JetStream Durable publication with stable identity JetStream acknowledges durable storage and deduplicates the deterministic message ID inside its configured window. The same ID remains in the envelope so consumers can enforce a longer boundary.

In practical terms, PostgreSQL and Redis provide one atomic destination effect; OpenSearch converges to one version-protected materialized state after replay; and NATS provides one durable transaction identity with a clearly bounded broker-deduplication window. Third-party connectors remain conservatively at-least-once unless their own destination contract proves a stronger outcome.

SyncDestinationConformanceSuite verifies changed-payload redelivery both in the same process and after a new connector instance starts. The official connector tests then verify destination-specific atomicity, partial-write, deduplication-window, versioning, and checkpoint failure boundaries. The public SyncDestinationCapabilities flags expose which mechanics a connector supports.

Transform definitions carry a canonical SHA-256 fingerprint. A mismatch moves the pipeline to Rebuilding and requires an explicit rebuild or migration; BlueTusk does not silently reinterpret existing destination data.

CompositeSyncTransform builds that fingerprint from the source mapper and every ordered transform stage. SyncPredicateTransformStage supplies explicit, versioned filtering. JsonSyncTransformStage supplies bounded JSON redaction, root enrichment, object flattening, and tenant routing. Its configuration is canonicalised into the pipeline fingerprint, so changing a predicate version, redaction path, enrichment value, flatten separator, tenant path, or size limit requires an explicit rebuild or migration.

JSON materialisation accepts object-valued application/json payloads only and enforces input, output, and configuration bounds. Redaction paths always use dotted source paths even when flattened output uses another separator. Tenant routing is resolved before redaction, rejects missing or empty tenant values by default, requires deletes to retain their partition key, and rejects unscoped collection deletes. Stages may filter and rewrite mapped content but cannot invent a transaction change ID or snapshot row ID. Those rules keep replay and destination deduplication stable.

Transformation sandbox

SandboxedSyncTransformStage runs untrusted transformation configuration as a finite JSON instruction program. It does not load assemblies, compile expressions, invoke delegates, use reflection, or expose file, network, process, clock, environment, or service-provider access. The only operations are remove, set, copy, route, conditional drop, and equality requirement. There are no branches other than those finite predicates and no loops.

var sandbox = new SandboxedSyncTransformStage(
    new SyncTransformSandboxOptions
    {
        Name = "public-orders",
        Version = "v4",
        Instructions =
        [
            SyncSandboxInstruction.RequireEquals("kind", "\"order\""),
            SyncSandboxInstruction.Copy("customer.name", "displayName"),
            SyncSandboxInstruction.Remove("customer.email"),
            SyncSandboxInstruction.Set("metadata.source", "\"cdc\""),
            SyncSandboxInstruction.Route("tenant.id"),
            SyncSandboxInstruction.DropWhenEquals("status", "\"cancelled\""),
        ],
        MaximumDocumentBytes = 256 * 1024,
        MaximumBatchBytes = 8 * 1024 * 1024,
        MaximumOperationsPerBatch = 250_000,
        MaximumExecutionTime = TimeSpan.FromSeconds(2),
    });

Construction canonicalises literal JSON and includes instruction order and every execution limit in the transform fingerprint. Changing the program or a limit therefore requires the same explicit destination rebuild or migration as any other transform-version change.

Execution is bounded independently by instruction count, mutation count, input and output document size, aggregate batch bytes, JSON depth, operation count, cancellation, and elapsed time. A violation becomes a poison record and follows the pipeline’s configured pause/quarantine policy. Stable change IDs and snapshot row IDs are preserved. A routing program rejects collection-wide deletes and, by default, requires ordinary deletes to retain the partition key established by the source mapper.

This is the V1 sandbox contract. It deliberately does not claim that an in-process C# delegate or a child process without an operating-system policy is sandboxed. Native, managed-assembly, and general-purpose script plug-ins remain outside the trusted boundary.

Reproduce the sandbox contract suite with:

dotnet test tests/BlueTusk.Sync.Tests/BlueTusk.Sync.Tests.csproj --filter FullyQualifiedName~SyncTransformTests

Poison transformations pause by default. QuarantineAndAdvance is accepted only with an explicit durable quarantine sink, and the source delivery is not acknowledged until that sink confirms storage. Destination outages and rejected durability confirmations nack the delivery and fault the pipeline for safe redelivery.

Quarantine replay

QuarantineAndPause is the replay-oriented poison policy. It durably stores the quarantine record, acknowledges the source transaction, and leaves the pipeline paused at that exact boundary. SyncQuarantineReplayCoordinator then reads the original transaction from an ISyncQuarantineReplaySource, verifies the stored, requested, and running transform fingerprints, reruns the transform, applies the transaction, and compare-and-set resolves the quarantine record. Destination application always precedes resolution, so a crash between them safely retries an idempotent destination operation.

PostgreSqlRelaySyncQuarantineReplaySource reads the exact retained transaction from the durable relay without moving any consumer-group checkpoint. If relay retention has expired, replay returns SourceTransactionUnavailable and leaves the record unresolved. PostgreSQL, Redis, OpenSearch, and NATS implement the replay destination contract. PostgreSQL, Redis, and OpenSearch also implement the durable read/resolve quarantine store; NATS can use any separately configured store.

Materialising destinations atomically reject replay when their checkpoint is already beyond the quarantined commit position. This prevents an old upsert or delete from overwriting later state; the operator must use authoritative reconciliation or rebuild instead. Unscoped collection deletes are never replayed into a materialised destination. QuarantineAndAdvance remains an explicit continue-on-poison policy, but its record may therefore become non-replayable after later destination progress.

The dashboard exposes the separately authorised and audited ReplayQuarantine operation when a pipeline reports quarantined transactions. The application operation handler selects the unresolved record and passes the request operation ID into the replay coordinator, preserving idempotency across HTTP retries. Resume the worker only after replay reports Completed or AlreadyCompleted.

PostgreSQL, NATS JetStream, Redis, and OpenSearch connector slices are implemented and pass the same executable snapshot-plus-stream recovery contract. The shared count, key-set, and partitioned content-hash engine plus PostgreSQL, Redis, and OpenSearch repair paths are implemented and live-tested. The destination-neutral rebuild coordinator is implemented with an explicit cutover barrier, and the in-process hosting package provides named workers, health, telemetry, and one-way worker handoff. The production relay change-stream adapter provides bounded reads, continuous fenced lease renewal, and acknowledgement-after-destination ordering. Restart-aware relay snapshot bootstrap now reserves retention before export, restarts abandoned epochs, and resumes completed pipelines without resetting the destination. Dashboard read models now expose pipeline state, sampled throughput, checkpoint lag, failures, quarantine, retries, and rebuild/reconciliation state without leaking worker exception messages. Separately authorized retry, reconcile, and rebuild controls now pass exact confirmations through the audit-before-mutation control plane executor. The coordinated 1.1.0-rc.1 package train is public. Stable 1.1.0 remains non-publishable until the exact 24-hour endurance and final stable-release gates pass.

The executable Sync release endurance gate repeatedly runs the core/hosting contracts and all four live connector suites for 24 hours, then emits a versioned evidence report. A local one-cycle smoke proves the orchestration only; it does not satisfy the release gate.

Retry, rate limits, and backpressure

Sync never starts another source transaction while the current delivery is in destination work, retry delay, or rate-limit delay. This sequential pull model propagates backpressure to Streams without an unbounded pipeline-owned queue and preserves the source transaction order.

SyncRetryOptions applies a bounded exponential backoff with configurable jitter and a hard attempt ceiling. No exception is retried by default: an application must register ISyncRetryClassifier, or its destination must implement that interface, and explicitly classify each failure as transient. The transform runs once and every attempt receives the same immutable batch or quarantine record, including stable IDs and timestamps. Retry exhaustion faults the pipeline and nacks the active Streams delivery; the checkpoint cannot move past an unconfirmed destination position.

SyncRateLimitOptions can independently cap source transactions per second and transformed bytes per second. Each destination attempt, including a retry, consumes capacity. Waiting occurs inline before the attempt, so rate limiting does not introduce reordering or a second buffering layer. Snapshot batches use the transformed-byte limit but do not count as CDC transactions.

Runtime status exposes cumulative retry attempts and throttle duration. BlueTusk.Sync.DependencyInjection publishes them through bluetusk.sync.retries and bluetusk.sync.throttle.duration; the health registry includes the same values for control-plane consumers. Retry classifiers are resolved from dependency injection and remain operator policy rather than a connector silently guessing whether a database, broker, or HTTP failure is safe to retry.

Shared destination conformance

BlueTusk.Sync.Testing contains SyncDestinationConformanceSuite, the single connector acceptance scenario used by the PostgreSQL, NATS JetStream, Redis, and OpenSearch live test projects. It verifies:

  • provisioning and transform-fingerprint ownership;
  • idempotent snapshot batches and completed snapshot state after a new destination instance starts;
  • exact durable commit positions, same-instance duplicate delivery, and process-restart redelivery without replacing accepted content;
  • explicit RebuildRequired results for transform-version drift; and
  • durable, idempotent quarantine for connectors that expose ISyncQuarantineSink.

An in-memory reference harness also proves the kit rejects a destination that reports a checkpoint beyond the applied source transaction. The live variants run in their existing connector CI jobs; PostgreSQL runs across versions 15–19.

Reconciliation and repair

SyncReconciler supports three explicit depths: count, partitioned key set, and partitioned exact-content SHA-256. Key partitions are derived from the high 32 bits of SHA-256 and compared in bounded streams ordered by hash and UTF-8 key. Results retain a configurable number of representative differences while exact totals continue to accumulate. Count-only equality is deliberately reported as count equality; it does not claim content equality.

Repair is unavailable in count-only mode. For key-set or content-hash runs, the authoritative reader must include replacement content and the destination must implement ISyncRepairSink. Repairs are idempotent upserts/deletes sent in bounded batches. A repaired result remains a mismatch and sets RequiresVerification; only a subsequent clean comparison proves convergence. Repair never advances the source transaction checkpoint.

SyncPipeline.ReconcileAsync serializes reconciliation with delivery, exposes the Reconciling state, restores the previous running/paused state after success, and faults with diagnostics on a reader or repair failure.

Zero-downtime rebuilds

SyncRebuildCoordinator creates or resumes an isolated destination generation, runs a consistent snapshot with full-epoch restart after exporter loss, and then acquires an ISyncRebuildCutoverBarrier. The barrier must quiesce the active pipeline and capture the durable relay head as an exact transaction commit-end position. It remains held while the new generation consumes transaction-preserving catch-up, verifies, and performs the destination’s atomic routing swap. This makes the cutover target an observed post-snapshot boundary instead of an unsafe position guessed before the snapshot began.

Verification has two mandatory layers. ISyncRebuildDestination first checks generation-owned metadata and storage integrity. ISyncRebuildVerifier then checks the rebuilding materialisation against the authoritative transformed source view. SyncReconciliationRebuildVerifier supplies the default bounded implementation and accepts only non-repairing partitioned content-hash requests; count equality or old-generation equality cannot authorise a cutover because a new transform may legitimately change both content and cardinality.

Every catch-up transaction is acknowledged only after the destination confirms the exact commit-end position. Wrong positions, transform failures, stream reordering, or an early finite stream nack the active delivery where possible, release the barrier, and leave the rebuilding generation inactive. Verification failure also releases the barrier without activation. Progress reports are observational and cannot alter durability semantics.

After the routing swap, the coordinator explicitly commits the worker handoff while the barrier is still held. Disposing an uncommitted lease resumes the previous worker; once handoff begins, disposal must keep that worker quiesced so it cannot process the old transform against the activated generation. A handoff failure reports completed activation and requires forward operator recovery, never rollback.

Previous-generation retirement is optional and occurs only after activation. If retirement fails, SyncRebuildRetirementException explicitly reports that activation completed and must not be rolled back. Running the coordinator again is safe: destination preparation resumes its durable build metadata, while an interrupted snapshot starts a new epoch and clears the isolated generation before replay.

In-process hosting and cutover

BlueTusk.Sync.DependencyInjection registers named pipelines with AddBlueTuskSync().AddHostedPipeline<TTransform, TDestination>(). Each worker owns one long-lived dependency-injection scope, provisions its destination, runs the Streams snapshot-then-stream coordinator, and leaves a faulted worker isolated while the other registered pipelines continue. Source factories remain explicit so an application cannot accidentally share a replication session or consumer group between pipelines.

AddHostedPipelineSource<TTransform, TDestination>() accepts an ISyncPipelineSource when the source owns a restart-aware lifecycle. PostgreSqlRelaySyncPipelineSource acquires and renews its independently checkpointed group before asking PostgreSQL to export a snapshot. A versioned relay snapshot run records the transform fingerprint, epoch, and consistent LSN. Reserved state left by exporter/session loss causes a safe new epoch; completed state resumes the group without another destination reset.

After snapshot completion, retained relay transactions through the snapshot’s consistent LSN are acknowledged as already represented. Later transactions are passed to SyncPipeline, which advances the group only after the destination confirms the exact commit-end LSN. Nack, abandonment, lease loss, or a process crash therefore causes safe at-least-once redelivery. A Latest group still cannot prove a no-gap bootstrap and is not used by this source’s default earliest-retained reservation protocol.

BlueTuskSyncHealthRegistry exposes immutable operational snapshots. The readiness check is unhealthy for faults, transform rebuild requirements, or hosting errors; paused/stopped-only deployments are degraded. Activities and metrics use the stable BlueTusk.Sync instrumentation name and report bounded pipeline identifiers, acknowledged transaction counts, snapshot rows, failures, and transaction duration.

AddRebuildCutover<TPositionProvider, THandoffHandler>() connects the shared rebuild coordinator to a hosted active worker. The barrier waits for the current delivery boundary, verifies the worker is running, captures a durable target, and holds all further consumption through verification and activation. A failed or cancelled pre-activation rebuild releases the worker. Once CompleteHandoffAsync begins, the old worker is permanently cancelled before the restart-safe handoff handler is invoked and can never resume against the new generation.

AddPostgreSqlRelayRebuildCutover<THandoffHandler>() uses the production relay’s separate control data source through PostgreSqlRelaySyncCutoverPositionProvider. The target is the latest durable relay commit position, or the snapshot’s consistent baseline when no later transaction exists. The rebuild source and handoff handler must use an independent relay group whose checkpoint is bound to that same snapshot epoch.

BlueTusk.Sync.Aspire wires source, destination, and—by default—a distinct durable-relay control resource into an Aspire worker. Its options carry the pipeline, group, transform version, destination protocol, reconciliation, and rebuild settings through standard hierarchical configuration. Direct-slot mode is an explicit helper that omits the control resource; the durable-relay helper rejects using the source database itself as control storage.

PostgreSQL destination

BlueTusk.Sync.PostgreSql stores an opaque materialised document collection and the pipeline checkpoint in the same PostgreSQL database transaction. It locks the pipeline row, skips mutation work for an already-applied commit position, and advances the checkpoint only after every mutation succeeds. A custom IPostgreSqlSyncMutationWriter can target application-specific tables while retaining the same atomic checkpoint boundary.

The default writer folds repeated operations to the final per-key result while preserving collection-delete ordering, then sends bounded multi-row commands instead of one database round trip per document. The live acceptance suite covers batches beyond one command chunk as well as retry deduplication.

The default document writer also exposes server-partitioned hash reconciliation and transactional repair. PostgreSQL computes the shared SHA-256 partition in SQL, streams rows in deterministic order, and applies a bounded repair batch in one database transaction without touching the pipeline checkpoint. A custom mutation writer does not advertise reconciliation because BlueTusk cannot infer how to inspect or repair an application-owned schema.

Snapshot reset, batches, and completion are guarded by the active snapshot epoch and transform fingerprint. The destination also implements a durable, deduplicated quarantine sink. Document and transaction byte ceilings are validated before opening the write transaction.

NATS JetStream destination

BlueTusk.Sync.Nats publishes one versioned binary envelope for each source transaction. It waits for JetStream’s persistence acknowledgement before returning the exact durable source position, so the Sync pipeline cannot acknowledge a partially published transaction. Snapshot reset, start, batch, and completion are also individually durable envelopes.

Every publish uses a fixed-size SHA-256 message ID derived from the pipeline, source, transform version, and transaction or snapshot identity. JetStream deduplicates redelivery inside its configured duplicate window; the same stable identity remains inside the envelope so downstream consumers can deduplicate beyond that window. BlueTusk still advertises at-least-once delivery.

The envelope has a magic header, explicit format version, bounded payload size, and SHA-256 integrity footer. Consumers decode it with NatsSyncEnvelopeReader. Mutation records retain stable change or snapshot row IDs, collection/key routing, content type, partition key, and opaque content.

Provisioning creates a file-backed, limits-retained JetStream stream by default. The stream carries ownership metadata for the envelope format, pipeline, source, transform, and subject. Existing metadata and retention settings are validated before publishing; drift pauses provisioning. A transform fingerprint change returns RebuildRequired, so operators must provision a new stream generation or explicitly migrate routing rather than reinterpret existing events.

The duplicate window must cover the expected worker recovery interval. Retain stable IDs downstream even when using a long window because redelivery after the window is valid at-least-once behaviour. Set CreateStream to false when stream creation is managed externally; BlueTusk will still validate the stream contract.

For local acceptance, start a JetStream-enabled NATS server and run:

$env:BLUETUSK_NATS_URL = 'nats://localhost:4222'
dotnet test tests/BlueTusk.Sync.Nats.Tests/BlueTusk.Sync.Nats.Tests.csproj

The live suite proves whole-transaction persistence, duplicate recovery after a destination restart, snapshot lifecycle deduplication, transform-generation rejection, and stable stream message counts.

Redis destination

BlueTusk.Sync.Redis stores materialised documents and the source checkpoint in one Redis Lua operation. All keys for a pipeline use the same generated Redis Cluster hash tag, so an atomic batch never crosses slots. The script checks the source, transform, monotonic fixed-width commit position, key types, and every operation before writing; a predictable failure therefore cannot leave a partial transaction or advance its checkpoint.

Repeated mutations are folded to their final per-key outcome before the script runs, while the last collection delete remains ordered before subsequent upserts. Configurable document, transaction-byte, and mutation-count ceilings bound Lua execution time and Redis argument memory. Transactions beyond those limits pause safely for operator action instead of blocking Redis indefinitely.

Documents use a small versioned binary value with the stable source change or snapshot-row ID, content type, partition key, opaque content, and a SHA-256 integrity footer. Applications can inspect a materialised value with ReadDocumentAsync or decode an exported value with RedisSyncDocumentReader.

Snapshot reset atomically removes registered materialisations and clears the checkpoint before activating a new epoch. Snapshot batches are idempotent, and completion prevents late batches for the epoch. Quarantine records use a stable transaction field and HSET NX, so retrying quarantine-and-advance cannot add duplicates.

Quarantine values use a versioned JSON document and compare-and-set resolution; legacy newline records remain readable. Replay uses one Lua script to verify the source, transform, and exact checkpoint/transaction boundary, apply all mutations, and advance that checkpoint atomically.

Redis format version 2 maintains a same-slot sorted reconciliation index beside each collection hash. Lua writes update the document, index, registry, and CDC checkpoint atomically. Partition reads use bounded score ranges instead of rescanning the whole collection, and repair updates the document hash and index in one Lua call without changing the CDC checkpoint.

The live Redis suite deliberately introduces a wrong-type destination key and proves preflight rejection occurs before any mutation. It also covers retry, restart, collection-delete ordering, snapshot reset/completion, quarantine, and transform rebuild requirements.

OpenSearch destination

BlueTusk.Sync.OpenSearch uses one bounded NDJSON bulk request as the source transaction delivery unit. It assigns SHA-256 document IDs and PostgreSQL commit-end LSNs as external_gte versions, so a partial request or ambiguous network failure can safely replay the entire transaction. The per-generation checkpoint is written only after every bulk item succeeds. A partially accepted bulk therefore never advances the checkpoint, and its successful items remain idempotent on retry.

OpenSearch bulk operations are independently applied by the server, so this connector deliberately does not advertise TransactionalBatches or a co-located checkpoint. Transaction preservation comes from bounded whole-batch submission, item-by-item response validation, stable external versions, and checkpoint-after-bulk ordering. Collection resets complete before the subsequent folded mutations are sent. JSON objects are the only accepted materialisation content.

Format version 2 creates a generation-owned reconciliation sidecar beside every materialised index. Each sidecar record contains the original logical key, its shared unsigned SHA-256 partition hash, the exact content hash, content type, and routing value; application JSON remains untouched and hashed document IDs never need to be reversed. A source mutation and its sidecar operation share the same replay-safe external version in one bulk request. Partial bulk failure cannot advance the checkpoint, and replay heals either half before progress is claimed. Count reads reject materialised/sidecar cardinality drift instead of silently comparing an incomplete view.

Partitioned sidecar scans use bounded search_after pages ordered by key hash and logical key. Repair looks up prior routing, removes an old routed copy when the routing value changes, and writes the application document plus sidecar without changing the CDC checkpoint. A subsequent reconciliation run is still required to prove convergence. Logical keys and page sizes have explicit operator-configured ceilings so reconciliation cannot create unbounded terms or responses.

Each transform generation writes to isolated concrete indexes. Stable aliases are attached to the initial generation, while a rebuild generation remains invisible. BeginRebuildAsync is restart-safe and copies the active collection registry, snapshot and catch-up writes target only the rebuild indexes, VerifyRebuildAsync checks every active/rebuild count, and CompleteRebuildAsync moves all aliases in one atomic OpenSearch aliases request. Previous generations are retained until an explicit RetireGenerationAsync call.

OpenSearch also implements ISyncRebuildDestination, allowing the shared coordinator to drive those connector-native generation operations without depending on OpenSearch types.

The control index owns versioned pipeline, collection, snapshot, checkpoint, and quarantine documents. Source and transform fingerprints are validated on every restart; a changed transform returns RebuildRequired. Index names, aliases, and document IDs contain hashes instead of application keys, and document, mutation-count, and encoded bulk-byte limits are checked before submission.

Quarantine resolution uses OpenSearch sequence-number/primary-term compare and set. Replay shares the normal external-versioned bulk path and rejects an already-advanced checkpoint before applying an old transaction.

For local acceptance, run an OpenSearch node without the security plug-in and then execute:

$env:BLUETUSK_OPENSEARCH_URL = 'http://localhost:9200'
dotnet test tests/BlueTusk.Sync.OpenSearch.Tests/BlueTusk.Sync.OpenSearch.Tests.csproj

The CI and local live suite use OpenSearch 3.7.0. It deliberately causes a mapping conflict after another item has succeeded, repairs and replays the same transaction, and then covers checkpoint deduplication, collection reset, snapshot lifecycle, quarantine, restart, transform isolation, count verification, atomic alias cutover, old-generation retirement, paged content-hash reconciliation, bounded repair, and checkpoint non-advancement. The design follows the official Bulk API and Manage Aliases API contracts.