This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Build (Debug — no doc requirements)
dotnet build RayTree.slnx
# Build (Release — used by CI)
dotnet build RayTree.slnx -c Release
# Run all unit tests (no Docker required)
dotnet test tests/RayTree.Core.Tests
dotnet test tests/RayTree.Plugins.InMemory.Tests
dotnet test tests/RayTree.EntityFrameworkCore.Tests
dotnet test tests/RayTree.OpenTelemetry.Tests
dotnet test tests/RayTree.Plugins.Compressors.{Brotli,Gzip,Lz4}.Tests
dotnet test tests/RayTree.Plugins.Serializers.{Json,MessagePack,Protobuf}.Tests
# Run a single test by name
dotnet test tests/RayTree.Core.Tests --filter "FullyQualifiedName~NoSerializer"
# Run integration tests (requires Docker — spins up containers via Testcontainers)
dotnet test tests/RayTree.Plugins.PostgreSQL.Tests
dotnet test tests/RayTree.Plugins.RabbitMQ.Tests
dotnet test tests/RayTree.Plugins.Kafka.Tests
dotnet test tests/RayTree.Plugins.Deduplication.Redis.TestsTreatWarningsAsErrors=true is global. Nullable warnings are always errors. All new public code must satisfy these constraints.
Centralized package versions live in Directory.Packages.props. Add new packages there; reference them in .csproj without a version attribute.
RayTree is a modular .NET 10 entity change-tracking library built on the outbox pattern. All change tracking flows through a single EntityChangeTracker that acts as the unified host for both the publisher and subscriber pipelines:
EntityChangeTracker
→ IOutbox (persist change + entity state)
→ OutboxPublisherService (polls outbox, serializes, publishes)
→ IQueuePublisher (broker-specific)
↓ MessageEnvelope (meta headers + byte[] Payload)
→ IQueueConsumer
→ ChangeSubscriber (internal: dedup, decompress, deserialize, dispatch)
→ ChangeHandlerAsync<TEntity>(EntityChange<TEntity>, CancellationToken)
-
EntityChangeTracker— the single runtime host. Constructor-injects aChangePublisher(required) and aChangeSubscriber?(optional).internal InitializeAsync()starts publisher loops (viaChangePublisher.InitializeAsync()) and initializes all consumer connections (ChangeSubscriber.InitializeAsync()parallelises consumer init viaTask.WhenAllso a single slow consumer — e.g. KafkaWaitForTopicagainst a missing topic — does not block the others) — called automatically byBuild()/BuildAsync(), never by callers directly. Public lifecycle surface:StartAsync(CancellationToken)starts all shared and isolated consumer loops (stores tasks internally);StopAsync()awaits those tasks (swallowsOperationCanceledException);RunCleanupAsync(TimeSpan retentionPeriod, CancellationToken)iterates all registered outboxes and callsCleanupPublishedAsync, returning total rows deleted.TrackXxxAsyncwrites to the internal outbox viaGetOutbox(entityType).PublisherandSubscriberareinternal— plugin assemblies access them viaInternalsVisibleTo; callers use the public API instead. -
ChangeTrackingBuilder/IChangeTrackingBuilder— unified fluent builder for both sides. Accepts an optionalILoggerFactory?constructor parameter;nullnormalizes toNullLoggerFactory.Instance, so existing call-sites that omit it continue to work. Global factories (UseOutbox<T>,UseSerializer<T>, etc.) apply to all entity types.UseSerializer/UseCompressorat the global level forward to both the publisher factory and the subscriber's global instance.UseSubscriberOptionsandUseDeduplicationStoreconfigure the subscriber globally. Per-entity overrides live inside.ForEntity<TEntity>(Action<IEntityBuilder<TEntity>>)which exposes both publisher methods (UseOutbox,UsePublisher,UseSerializer,UseCompressor,UseRepository) and subscriber methods (UseConsumer,OnInsert,OnUpdate,OnDelete,OnChange,UseSubscriberOptions).Build()/BuildAsync()produce a fully initializedEntityChangeTrackerwith the subscriber already attached. -
IEntityBuilder<TEntity>— generic per-entity configuration interface. Publisher side:UseOutbox,UsePublisher(IQueuePublisher),UseSerializer,UseCompressor,UseRepository. Subscriber side:UseConsumer(IQueueConsumer),UseSubscriberOptions,OnInsert,OnUpdate,OnDelete,OnChange.where TEntity : classis required because subscriber handler registration is typed. -
ChangePublisher— owns all publisher-side plugin registrations (IOutbox,IQueuePublisher,IChangeSerializer,IChangeCompressor,IRepositoryper entity type) inConcurrentDictionary<Type, …>, and manages theOutboxPublisherServiceinstances. Constructor signature:(ILoggerFactory loggerFactory, RayTreeMeter meter)— both are required non-null parameters. ExposesMeterasinternalforEntityChangeTrackerand tests (visible toRayTree.Core.TestsviaInternalsVisibleTo).InitializeAsync()initializes repositories, outboxes, publishers, then starts oneOutboxPublisherServiceper registered entity type, passing the meter to each. Parallel toChangeSubscriberon the subscriber side. -
OutboxPublisherService— background polling loop per entity type: reads unpublished changes → serialize → compress → wrap inMessageEnvelope→ publish → mark published → rotate outbox (see below). Constructor signature:(ChangePublisher, Type entityType, OutboxPublisherOptions, ILoggerFactory, RayTreeMeter meter)— the meter is a required non-null parameter. Caches_entityTag = RayTreeMeter.EntityTag(entityType)in the constructor for hot-path reuse. Emitsraytree.outbox.batch.sizeper batch; per attempt recordsraytree.outbox.publish.duration; on success recordsraytree.outbox.messages.published+raytree.outbox.publish.attempts+raytree.outbox.lag.duration; on exhaustion recordsraytree.outbox.messages.failed+raytree.outbox.publish.attempts(so retry-shape histograms reflect worst cases too).PublishChangeAsyncrecordsraytree.outbox.payload.size(bytes) with the envelope's payload length.MaybeRunCleanupAsyncrecordsraytree.outbox.records.cleanedandraytree.outbox.stale_unpublished.removed. WhenOutboxPublisherOptions.UseNotificationChannel = true, usesFallbackPollingIntervalinstead ofPollingIntervalas the inter-batch sleep, demoting the service to a safety-net role (catching anything the NOTIFY fast-path misses) whileNotificationBasedPublisherhandles normal delivery. Each batch is published in parallel viaParallel.ForEachAsyncbounded byMaxPublishConcurrency. When a batch is full (changes.Count == BatchSize), the inter-batch sleep is skipped entirely to drain the backlog immediately. Logs start/stop atInformation; batch errors atError; per-retry warnings atWarning; exhausted retries atError. After each batch it callsMaybeRunCleanupAsync, which fires once on the first tick and then respectsOutboxPublisherOptions.CleanupInterval(default 1 h). Rotation logs: start atDebug; records deleted atInformation; nothing to delete atDebug; stale unpublished records found atWarning; errors atError(isolated — a cleanup failure does not abort the publish loop). -
OutboxPublisherOptions—PollingInterval(default 5 s),BatchSize(default 100),MaxPublishConcurrency(default 1 — sequential; increase for throughput when message ordering within a partition is not required),MaxRetryCount(default 3),RetryDelay(default 1 s),UseNotificationChannel/NotificationChannel/FallbackPollingIntervalfor PostgreSQL NOTIFY/LISTEN. Rotation options:CleanupRetentionPeriod(default 7 days — how old a published record must be before deletion),CleanupInterval(default 1 h — how often rotation runs),StaleUnpublishedThreshold(defaultnull— opt-in; when set, unpublished records older than this value are also removed with aWarninglog so operators notice stuck queues). -
MessageEnvelope— the only thing that crosses the queue boundary. Contains metadata fields plusbyte[] Payload(already serialized+compressed entity state). Serialization happens on the publisher side; deserialization on the subscriber side. -
IChangeSerializer/IChangeCompressor— stream-based interfaces. Serializers handleEntityChange<TEntity>with typedState.NoOpCompressorPlugin(built-in) is a passthrough for testing. -
IOutbox— ten methods:InitializeAsync,WriteAsync<TEntity>, twoGetUnpublishedAsync<TEntity>overloads (by batch size; or byChangeType?,DateTime? since, batch size),MarkPublishedAsync,TryClaimForPublishingAsync(id)(atomically setspublished = TRUE WHERE published = FALSE, returnstrueif this caller claimed it),RevertClaimAsync(id)(setspublished = FALSE— used to undo a claim when publish fails so the fallback loop can retry),GetByIdAsync<TEntity>,GetPendingCountAsync(Type entityType)(returnslong— count of unpublished records for the given entity type; used by theraytree.outbox.pendingobservable gauge; throwsInvalidOperationExceptionif called with the wrong entity type for a typed implementation),CleanupPublishedAsync(retentionPeriod),CleanupStaleUnpublishedAsync(staleThreshold). Both cleanup methods return the count of rows deleted. -
RayTreeMeter(src/RayTree.Core/Telemetry) — owns a singleSystem.Diagnostics.Metrics.Meter("RayTree", <assembly-version>)and all 14 outbox/subscriber instruments (counters, histograms, and theraytree.outbox.pendingobservable gauge). The fourraytree.connection.*instruments were removed — connection recovery is now observed via logs only. Created byChangeTrackingBuilderwhenUseMeteris not called;EntityChangeTrackerdisposes it only when it created it (theownsMeterflag tracks this). When the caller supplies their own meter viaUseMeter(RayTreeMeter), the caller owns disposal.RegisterPendingGauge(Func<IEnumerable<(Type, IOutbox)>>)(nowinternal) registers theraytree.outbox.pendingobservable gauge; the callback samplesIOutbox.GetPendingCountAsyncper registered entity type and caches results forDefaultPendingCacheTtl = 10sto bound DB round-trips. All instruments are tagged withentity_type; change-specific instruments addchange_type; skipped-message counters addreason. The class exposesinternalinstrument properties andinternalemit methods (RecordPublishSuccess,RecordPublishFailure,RecordPayloadSize,RecordBatchSize,RegisterPendingGauge) consumed byEntityChangeTracker,OutboxPublisherService,ChangeSubscriber, and the IVT-privilegedNotificationBasedPublisher. The public surface is now construct-and-observe only:MeterName, the constructors,DefaultPendingCacheTtl, andDispose()— all metric emission is internal.RayTreeMeter.MeterNameis a public constant equal to"RayTree". See docs/opentelemetry-metrics.md for the full instrument inventory and unit conventions (sfor durations,Byfor bytes). -
ChangeSubscriber— internal implementation detail owned byEntityChangeTracker. ReceivesMessageEnvelopefrom anIQueueConsumer, deduplicates onCorrelationIdviaIDeduplicationStore, decompresses and deserializes the payload using reflection (DeserializeCoreAsync<TEntity>viaMethodInfo.MakeGenericMethod), then dispatches to registeredChangeHandlerAsync<TEntity>handlers with retry.ConsumeFromConsumerAsyncandConsumeFromQueueAsyncuseParallel.ForEachAsyncbounded bySubscriberOptions.MaxDegreeOfParallelism(default 1 — sequential, preserves per-partition ordering). When all handlers succeed, callsMaybeDedupCleanupAsyncto periodically evict old dedup entries (gated bySubscriberOptions.DeduplicationCleanupInterval); aSemaphoreSlimgate ensures only one concurrent invocation runs cleanup at a time. When a handler exhausts retries and throws (SkipOnFailure = false), reverts the dedup mark viaRevertProcessedAsyncbefore rethrowing — this ensures the redelivered message can be retried rather than silently dropped by a persistent dedup store. Still public for direct low-level usage (e.g., standalone subscriber tests), but not the primary API. Constructor signature:(ILogger<ChangeSubscriber> logger, RayTreeMeter meter, IDeduplicationStore? dedupStore = null, SubscriberOptions? options = null)— logger and meter are required non-null parameters. Emitsraytree.subscriber.messages.skipped(withreason="unknown_type"orreason="no_handler"),raytree.subscriber.messages.deduplicated,raytree.subscriber.messages.processed, andraytree.subscriber.lag.durationfromProcessMessageAsync.InvokeWithRetryAsyncrecordsraytree.subscriber.processing.durationper attempt (success or failure),raytree.subscriber.handler.attemptson every completed dispatch (success or failure), andraytree.subscriber.handler.failureson exhaustion. Logs unknown entity types atWarning; dedup hits atDebug; no-handler-match atDebug; successful message dispatch atDebug; dedup revert on handler failure atWarning; retry attempts atWarning; SkipOnFailure drops atError; dedup cleanup errors atError. -
ChangeSubscriberBuilder/IChangeSubscriberBuilder— standalone subscriber-only builder. Produces aChangeSubscriberviaBuild().ChangeTrackingBuildercomposes one internally;BuildInternal()calls_subscriberBuilder.Build()and passes the result to theEntityChangeTrackerconstructor.
| Package | What it provides |
|---|---|
RayTree.Plugins.PostgreSQL |
PostgreSqlOutbox<TEntity> — stores changes as flat columns (one column per entity property via EntityColumnMapper). Constructor: PostgreSqlOutbox<TEntity>(PostgreSqlOutboxOptions, ILoggerFactory) — both params required. PostgreSqlRepository<TEntity> constructor: PostgreSqlRepository<TEntity>(PostgreSqlRepositoryOptions, ILoggerFactory) — both params required. Builder extension methods accept ILoggerFactory? loggerFactory = null and default to NullLoggerFactory.Instance. EntityColumnMapper honours System.ComponentModel.DataAnnotations / Schema attributes: [NotMapped] excludes a property; [Column("name")] overrides the column name suffix (the state_ prefix is always kept to avoid collisions with outbox metadata columns); [Column(TypeName = "JSONB")] sets the PostgreSQL type verbatim; [Required] forces NOT NULL on reference types; [MaxLength(n)]/[StringLength(n)] emits VARCHAR(n) instead of TEXT; [Table("name")] on the entity class is used as the base name when deriving default outbox/source table names; [Key] (one or more properties) identifies the business primary key — PostgreSqlRepository uses these for INSERT/UPDATE/DELETE/SELECT and adds a UNIQUE index on the corresponding state_* columns in the source table; for composite keys pair [Key] with [Column(Order = n)] to control column order. 1D arrays of primitive types are automatically mapped to the corresponding PostgreSQL array column type: int[] → INTEGER[], long[] → BIGINT[], bool[] → BOOLEAN[], string[] → TEXT[], Guid[] → UUID[], float[] → REAL[], double[] → DOUBLE PRECISION[], decimal[] → NUMERIC[], DateTime[]/DateTimeOffset[] → TIMESTAMPTZ[], short[]/byte[]/sbyte[] → SMALLINT[]; nullable-element arrays (e.g. int?[]) strip the nullable wrapper before mapping the element type. Multi-dimensional arrays are not supported — declare the column type explicitly via [Column(TypeName = "...")] if needed. When reading values back, EntityColumnMapper.ConvertFromDb first attempts a direct CLR assignability check (Npgsql returns the correct array type natively) and falls back to Convert.ChangeType for scalar numeric coercions. Both CleanupPublishedAsync and CleanupStaleUnpublishedAsync delete in batches (PostgreSqlOutboxOptions.CleanupBatchSize, default 1000) using a DELETE … WHERE id IN (SELECT id … LIMIT @BatchSize) loop to avoid large single-statement locks and WAL spikes. InitializeAsync manages schema automatically — no flag required, always active. Fresh table path: single CREATE TABLE IF NOT EXISTS (columns + indexes). Existing table path: column diff via SchemaMigrator (adds missing columns with ALTER TABLE … ADD COLUMN IF NOT EXISTS; guards NOT NULL without default on non-empty tables by throwing InvalidOperationException; logs Warning for orphan columns and type mismatches) + index diff via IndexMigrator (creates missing indexes; drops and recreates indexes whose definition changed — uniqueness, column order, or WHERE clause; logs Warning for orphan indexes). Internal infrastructure: SchemaInspector (static — TableExistsAsync, GetColumnsAsync via information_schema.columns, GetIndexesAsync via pg_index catalog using unnest(indkey::smallint[]) WITH ORDINALITY for ordered columns and pg_get_expr for WHERE, ExecuteDdlAsync, TableHasRowsAsync); SchemaMigrator (column diff, parameterised delegate for DDL generation and orphan filter); IndexMigrator (index diff with schema-qualified DROP INDEX IF EXISTS public.{name}; WHERE clause comparison is case-insensitive and trimmed); PostgreSqlTypeNormalizer (maps information_schema type fields to canonical DDL strings). NotificationBasedPublisher — NOTIFY/LISTEN fast-path with polling fallback; bounded by NotificationBasedPublisherOptions.MaxConcurrentNotifications (default 16) via a SemaphoreSlim in OnNotification; fallback polling uses Parallel.ForEachAsync with MaxPublishConcurrency (default 1 — sequential). Logs LISTEN connection loss at Warning (once, on the first unhealthy tick), recovery at Information, and claim contention (record already taken by another publisher) at Debug. |
RayTree.Plugins.InMemory |
InMemoryQueue implements both IQueuePublisher and IQueueConsumer via Channel<MessageEnvelope>. Use for tests and local dev. |
RayTree.Plugins.Kafka |
KafkaPublisher + KafkaConsumer. Publisher key: KafkaPublisherOptions.KeySelector (Func<MessageEnvelope, string>) selects the Kafka partition key for each message. Default: envelope => $"{EntityType}:{EntityId}" — all changes for the same entity land on the same partition, preserving per-entity ordering. Override to shard by any envelope field (e.g. tenant, aggregate root). Consumer uses a dedicated background thread (channel-based) because Confluent.Kafka requires all Consume/Commit/Seek calls on one thread. KafkaConsumer(KafkaConsumerOptions, ILoggerFactory) — both params required. KafkaConsumerOptions.AckAfterHandler (default false) defers the offset commit; subscriber posts the ConsumeResult plus a Commit/SeekBack action through an internal post-handler channel that the poll thread drains at the top of each iteration (when items are queued, the next Consume() uses TimeSpan.Zero so commits don't wait a full poll cycle). AcknowledgeAsync → Commit; NegativeAcknowledgeAsync → Seek(TopicPartitionOffset) so the failed message is redelivered in the same consumer's lifetime, not just on restart. Parse-failure path always commits immediately to avoid poison-pilling the partition. Requires SubscriberOptions.MaxDegreeOfParallelism = 1 per partition when AckAfterHandler = true. Topic wait (opt-in, both options classes): WaitForTopic (bool, default false), TopicWaitInterval (TimeSpan, default 5 s), TopicWaitTimeout (TimeSpan?, default null — unlimited). When WaitForTopic = true, InitializeAsync probes the configured Topic via IAdminClient.GetMetadata and retries while the response indicates the topic is not yet available — defined as: empty Topics collection, per-topic ErrorCode.UnknownTopicOrPart, per-topic ErrorCode.LeaderNotAvailable (transient during cluster bootstrap / partition-leader election), OR a transient transport-level KafkaException with Error.IsFatal == false and Error.Code in {Local_Transport, Local_AllBrokersDown, Local_Resolve, Local_TimedOut} — the dominant startup-ordering case where the broker pod has not yet finished starting. All other broker errors and fatal KafkaExceptions propagate immediately (authorization failures, unrecoverable client state). Each GetMetadata call is bounded by a fixed KafkaTopicProbe.MetadataCallTimeout (1 s) decoupled from TopicWaitInterval so cancellation latency and shutdown thread-pool occupancy are bounded regardless of the interval. The publisher routes the probe through the lazy GetProducerAsync path used by both InitializeAsync and PublishAsync, so callers that publish without explicit init still benefit; the consumer probes before allocating the native IConsumer handle AND re-checks cancellationToken.ThrowIfCancellationRequested() immediately after the probe to prevent handle leaks when cancellation arrives during a slow probe. The publisher uses two SemaphoreSlim instances: a one-shot _probeSemaphore gated by volatile bool _probeCompleted (so steady-state callers skip it entirely once the probe has run) and a separate microsecond-long _buildSemaphore for the producer build — splitting the two prevents concurrent PublishAsync callers from serializing behind a multi-second probe. KafkaPublisher.Dispose is idempotent (volatile bool _disposed guard) and uses a SafeRelease helper that swallows ObjectDisposedException from in-flight Release() calls during a Dispose-during-init race. KafkaPublisher(KafkaPublisherOptions, ILoggerFactory? = null) accepts an optional logger factory (null → NullLoggerFactory.Instance) so the probe can log progress; both builder extensions — KafkaBuilderExtensions.UseKafka(configure, loggerFactory) and KafkaSubscriberExtensions.UseKafka<TEntity>(configure, loggerFactory) — expose an optional ILoggerFactory? parameter so the documented fluent API can forward host logging (without this on the subscriber side the probe would silently drop all logs). Probe logging cadence matches TopologyProbe: first miss Information, subsequent misses Debug, recovery Information, timeout exhaustion Error. Use this in microservice deployments where the topic owner pod comes up after the consumer/publisher. Auto-create caveat: brokers with auto.create.topics.enable=true (the default on many distributions) create the topic in response to the probe itself, masking real misconfiguration — set the broker option to false in deployments that rely on this feature; the integration test container uses WithEnvironment("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "false") for the same reason. |
RayTree.Plugins.RabbitMQ |
RabbitMqPublisher + RabbitMqConsumer. Routing key: RabbitMqPublisherOptions.RoutingKeySelector (Func<MessageEnvelope, string>) selects the AMQP routing key for each message. Default: "{RoutingKey}.{EntityType}.{changeType}" (e.g. change.Order.insert) — consumers bind queues with wildcard patterns such as change.Order.* or change.*.insert. The default delegate reads RoutingKey at call time so changing that property after construction is always reflected; set a custom delegate to route by tenant, aggregate root, or any envelope field. RabbitMqPublisher(RabbitMqPublisherOptions, ILoggerFactory?) — options required, logger factory optional (null → NullLoggerFactory.Instance); UseRabbitMq(configure, loggerFactory) mirrors the same shape. Consumer uses AsyncEventingBasicConsumer buffered via Channel<MessageEnvelope>. RabbitMqConsumer(RabbitMqConsumerOptions) — options only; no logger. Message-receive errors silently NACK and requeue without logging (acknowledged exception to the logging placement rule — NACK/requeue is the correct recovery action and no context is available at that point). RabbitMqConsumerOptions.AckAfterHandler (default false) defers the broker ACK until after ChangeSubscriber confirms handler success — delivery tag is stashed in MessageEnvelope.Metadata via the internal RabbitMqEnvelopeMetadata accessor; AcknowledgeAsync issues BasicAckAsync; NegativeAcknowledgeAsync issues BasicNackAsync(requeue: true). Topology wait (opt-in, both options classes): WaitForTopology (bool, default false), TopologyWaitInterval (TimeSpan, default 5 s), TopologyWaitTimeout (TimeSpan?, default null — unlimited). When WaitForTopology = true, InitializeAsync probes externally-owned topology via AMQP passive declares (ExchangeDeclarePassiveAsync / QueueDeclarePassiveAsync) and retries only on NOT_FOUND (404) until the topology appears, the cancellation token is cancelled, or TopologyWaitTimeout elapses (rethrowing the last NOT_FOUND). Other channel- and connection-level errors (PRECONDITION_FAILED, ACCESS_REFUSED, etc.) propagate immediately. Each probe attempt uses a fresh channel from the existing connection because RabbitMQ closes the channel on any channel-level exception. The publisher probes when DeclareExchange = false; the consumer probes the queue when DeclareQueue = false and the binding-target exchange when ExchangeName is non-empty. Probe progress is logged by the publisher (first miss Information, subsequent misses Debug, recovery Information, timeout Error via TopologyProbe); the consumer's no-logger exception still holds, so consumer-side probes log nothing. Use this in microservice deployments where one service owns the topology and others connect later without strict startup ordering. |
RayTree.Plugins.Serializers.* |
JSON, MessagePack, Protobuf — each in its own package. |
RayTree.Plugins.Compressors.* |
Gzip, Brotli, LZ4 — each in its own package. |
IEntityBuilder<TEntity> exposes two consumer-binding methods that select the dispatch mode and fork the fluent chain into a mode-specific builder:
UseConsumer(IQueueConsumer)→ISharedHandlerBuilder<TEntity>— Shared mode. All handlers registered on the returned builder share a single broker delivery. They run sequentially in registration order. Dedup key:correlationId. If any handler exhausts retries withSkipOnFailure = false, the message-level dedup mark is reverted so redelivery will re-run every handler. Callers must chainUseSerializer/UseCompressoronIEntityBuilderbefore callingUseConsumer(those methods are not on the post-fork builder).UseConsumerFactory(Func<string, IQueueConsumer>)→IIsolatedHandlerBuilder<TEntity>— Isolated mode. Each named handler (OnInsert("name", handler)) gets its own broker subscription via the factory. Dedup key:$"{correlationId}:{handlerName}"— per-handler isolation. The framework starts one consume loop per(entity type, handler name)pair insidetracker.StartAsync. Handler names are stable deployment identifiers: renaming one is equivalent to creating a new subscription.
Handler registration methods (OnInsert, OnUpdate, OnDelete, OnChange) exist only on the post-fork builders, not on IEntityBuilder<TEntity> — the compiler prevents registering handlers before binding a consumer, and prevents mixing anonymous and named overloads.
ChangeType is required on every handler registration. OnChange(changeType, handler) takes a non-nullable ChangeType; the previous wildcard null form (which fired for every change type) was removed. Each handler binds to exactly one of Insert / Update / Delete. To react to multiple change types with the same logic, register the same delegate once per type — the dispatch matcher is now a strict equality check (h.ChangeType == envelope.ChangeType), and there is no implicit fall-through. This applies uniformly to IEntitySubscriberBuilder<TEntity>, ISharedHandlerBuilder<TEntity>, IIsolatedHandlerBuilder<TEntity>, and ChangeSubscriber.OnChange<TEntity> / RegisterIsolatedHandler<TEntity>. The HandlerRegistration.ChangeType field is correspondingly non-nullable.
Builder implementation classes (src/RayTree.Core/Handling): SharedHandlerBuilder<TEntity> delegates to EntitySubscriberBuilder<TEntity>; IsolatedHandlerBuilder<TEntity> accumulates (handlerName, changeType, handler) tuples and validates them (unique (action, name) pairs; factory returns distinct non-null instances) at Build() time.
Delivery-guarantee mode (Shared and Isolated): by default, RabbitMqConsumer and KafkaConsumer ACK/commit the broker delivery before handing the message to the subscriber (at-most-once — lowest latency, no broker-driven redelivery on crash). Opt in to at-least-once per consumer by setting RabbitMqConsumerOptions.AckAfterHandler = true or KafkaConsumerOptions.AckAfterHandler = true; the ACK is then deferred until ChangeSubscriber confirms all handlers completed successfully. On handler retry-exhaustion with SkipOnFailure = false, the consumer's NegativeAcknowledgeAsync requeues (RabbitMQ BasicNack(requeue: true)) or seeks back (Kafka Seek on the poll thread — redelivery happens in the same consumer process, no restart needed). Implementation: IQueueConsumer exposes default-no-op AcknowledgeAsync / NegativeAcknowledgeAsync methods; existing implementations inherit the no-ops and behave as at-most-once. Broker-private correlation state (delivery tag for RMQ, ConsumeResult for Kafka) travels with the envelope via MessageEnvelope.Metadata (lazy-allocated dict; consumed via take-on-read accessors so a double-ack is a silent no-op rather than a broker error). Kafka caveat: set MaxDegreeOfParallelism = 1 per partition when AckAfterHandler = true — out-of-order commits could advance the offset past in-flight messages. The ChangeSubscriber.ConsumeFromConsumerAsync(IQueueConsumer) overload is the one that participates in the Ack lifecycle; the custom-reader overload ConsumeFromQueueAsync<TQueue> stays at-most-once by design (no IQueueConsumer to dispatch against).
ChangeSubscriber— see Core section above.- Handler signature:
ChangeHandlerAsync<TEntity>(EntityChange<TEntity> change, CancellationToken ct).change.Stateis the fully-typed entity; it isnullwhen no serializer is registered for that entity type. IDeduplicationStore— three methods:TryMarkProcessedAsync(correlationId)(atomic add, returnsfalseif already present — the primary dedup gate),RevertProcessedAsync(correlationId)(removes the entry — called on handler failure so re-delivered messages can be retried),CleanupAsync(retentionPeriod)(evicts entries older than the retention window).InMemoryDeduplicationStoreis the default (process-local, cleared on restart). UseRedisDeduplicationStorefor distributed deployments where dedup state must survive restarts.SubscriberOptions—MaxDegreeOfParallelism(default 1 — sequential, preserves per-partition ordering; increase for throughput when handlers are order-independent),MaxRetries(retry attempts after the first call),RetryDelay,SkipOnFailure,DeduplicationRetention(default 24 h — how old a processedCorrelationIdmust be before cleanup eligibility),DeduplicationCleanupInterval(default 1 h — how oftenChangeSubscribercallsIDeduplicationStore.CleanupAsyncafter a successful message).
AddChangeTracking(services, configuration, configure)— the primary registration path. RegistersEntityChangeTrackeras a singleton (publisher + subscriber configured together),RayTreeMeteras a singleton, andChangeTrackingHostedServiceas a hosted service. ResolvesILoggerFactoryfrom the DI container and passes it tonew ChangeTrackingBuilder(loggerFactory)— no explicit logging setup is required from the caller. The DI-registeredRayTreeMeteris fed back into the builder viaUseMeter(sp.GetRequiredService<RayTreeMeter>())so the same instance is shared between the tracker and any consumer that injectsRayTreeMeterdirectly (e.g., custom instrumentation). Publisher loops start duringBuild()(via internalInitializeAsync). The hosted service callstracker.StartAsync(ct)on app startup to start consumer loops. Publisher options are bound fromChangeTracking:Publisher; subscriber options fromChangeTracking:Subscriber.ChangeTrackingHostedService— thinIHostedServiceadapter.StartAsyncdelegates totracker.StartAsync(_cts.Token);StopAsynccancels the token then awaitstracker.StopAsync(), logging graceful shutdown atInformation. No loop management logic lives here — the tracker owns that entirely. Constructor requires(EntityChangeTracker, ILogger<ChangeTrackingHostedService>)— both are auto-wired by DI.
Peer assembly — referenced only by applications that want OTel SDK wiring. RayTree.Core and RayTree.Hosting have zero OpenTelemetry.* dependencies, so apps that ignore this package receive no transitive OTel closure.
RayTreeInstrumentation—public static classwithpublic const string MeterName = "RayTree";. Use this constant in custom OTel views/filters instead of hard-coding the literal.MeterProviderBuilderExtensions.AddRayTreeMetrics(this MeterProviderBuilder builder)— thin pass-through that callsbuilder.AddMeter(RayTreeInstrumentation.MeterName). Does not configure exporters, views, or histogram bucket boundaries — callers retain full control.
All durations are emitted in seconds (s) per OTel semantic conventions; bytes use By. Suggested histogram bucket boundaries for each instrument are documented in docs/opentelemetry-metrics.md. Tests use an inline CapturingExporter : BaseExporter<Metric> + BaseExportingMetricReader instead of pulling in OpenTelemetry.Exporter.InMemory.
EntityChangeInterceptor hooks into SaveChangesAsync to automatically call TrackInsertAsync/TrackUpdateAsync/TrackDeleteAsync based on EF change tracker state.
Connection recovery is behavior + logs only — there are no connection metrics. The recovery-owning plugins each define their own options type carrying the exponential-backoff shape: PostgresConnectionRecoveryOptions (src/RayTree.Plugins.PostgreSQL/Resilience) and KafkaConnectionRecoveryOptions (src/RayTree.Plugins.Kafka). Both are near-verbatim copies of the same six members (Enabled, InitialDelay, MaxDelay, Factor, JitterFraction, MaxAttempts) with per-field init guards and a Validate() cross-field check. There is no shared Core options type (the former RayTree.Core.Resilience.ConnectionRecoveryOptions was removed) and no Hosting binding (the former ChangeTrackingRecoveryKeys + services.Configure<ConnectionRecoveryOptions>(...) calls were removed); callers configure recovery per plugin in the UseKafka / UsePostgreSqlOutbox configure lambda.
Recovery shape per plugin (logs only — disconnect/recovery are no longer counted):
-
postgres.notification(NotificationBasedPublisher.ListenLoopAsync) — single long-livedNpgsqlConnectionas a localawait using var(no class field). On a fault classified by the sharedPostgresFault.IsConnectionFaultstatic helper (usesNpgsql.PostgresErrorCodesconstants — admin/crash shutdown,08xxxconnection_exception family, transientNpgsqlException,SocketException/IOExceptioninner,ObjectDisposedException), logs aWarningonce per fault cycle, then runs an inline exponential-backoff loop bounded byNotificationBasedPublisherOptions.ConnectionRecovery(aPostgresConnectionRecoveryOptions) that disposes the connection, opens a fresh one, reissuesLISTEN. On reconnect logsInformation; onMaxAttemptsexhaustion logsErrorand exits the loop. Fallback polling continues throughout. -
postgres.outbox— observation only, no rebuild. Two seams classify faults:- OutboxPublisherService.ProcessBatchAsync:
HandleBatchErrorconsultsIOutbox.IsConnectionFault+ConnectionComponentin the existing batch-error catch. On a classified fault: demote per-batchErrorlog toWarning, set_unhealthySince. On the first subsequent successful batch: log anInformation"outbox connection recovered" entry viaEmitOutboxRecovered. - NotificationBasedPublisher.FallbackPollingLoopAsync:
ProcessSingleOutboxAsyncdoes the same classification per outbox, keyed on entity type via aConcurrentDictionary<Type, FallbackOutboxState>so each outbox tracks its own transitions independently.
IOutboxexposes three default-implemented members (IsConnectionFault,ConnectionComponent,ConnectionEndpoint) thatPostgreSqlOutbox<TEntity>overrides viaPostgresFault. The polling cadence is the natural retry — no rebuild loop. Non-fault exceptions preserve the originalErrorpath.InMemoryOutboxand third-partyIOutboximplementations inherit no-op defaults — no breaking change. - OutboxPublisherService.ProcessBatchAsync:
-
kafka.publisher—SetErrorHandlercallback flips an atomicInterlocked.CompareExchangeflag (_faultTicksUTC-ticks) and logs aWarning. The callback does no locking and no disposal (avoids the documented Confluent.Kafka footgun of disposing inside the native callback). The nextPublishAsyncentersGetProducerAsyncunder a single_buildLock, disposes the dead producer on a normal call thread, re-runs theWaitForTopicprobe (when enabled), builds a fresh producer, and logsInformation. No inner backoff loop — the outbox-publisher retry loop is the outer cadence. State: 2 fields (_producer,_faultTicks) + 1 lock. -
kafka.consumer— fatalKafkaExceptionon the dedicated poll thread → drops pending deferred-ack actions (broker redelivers via at-least-once contract on the new consumer's join) → disposes the dying consumer → runs an inline exponential-backoffRebuildConsumeron the same poll thread (re-runsWaitForTopicprobe +Subscribe), bounded byKafkaConsumerOptions.ConnectionRecovery(aKafkaConnectionRecoveryOptions). Backoff math is a small private static helper (Task.Delay(...).GetAwaiter().GetResult()is safe here — the poll thread is dedicated with no SyncContext). On exhaustion completes the buffer channel with the fatal exception. -
rabbitmq.publisher/rabbitmq.consumer— observe, don't own.RabbitMQ.Client.AutomaticRecoveryEnabled(library default — RayTree does not disable it) performs the actual rebuild. Publisher subscribes toConnectionShutdownAsync(Warning whenInitiator != Application),RecoverySucceededAsync(Information + duration),ConnectionRecoveryErrorAsync(Information only — per-internal-attempt). The publisher constructor no longer takes aRayTreeMeter. Consumer no longer subscribes to recovery events at all (its subscriptions existed solely to feed the removed metrics) and its constructor no longer takes aRayTreeMeter— it is silent for recovery, consistent with its no-logger posture.RabbitMqPublisherOptions/RabbitMqConsumerOptionsdo not exposeConnectionRecovery— the library'sNetworkRecoveryIntervalcontrols timing, exposed viaConnectionFactorynot through RayTree.
The retry math (~20 LoC: exponential backoff with ±JitterFraction jitter, capped at MaxDelay, optionally bounded by MaxAttempts) is duplicated across the two plugins that need it (Postgres LISTEN, Kafka consumer), as are the Validate()d options records. This is intentional — extracting a shared helper/type would require either InternalsVisibleTo exposure to plugins (architectural smell — plugins must consume Core via public API only) or a public-API commitment to a specific retry shape. Two short copies is cheaper than either of those.
- Unified builder:
IChangeTrackingBuilder.ForEntity<TEntity>()takesAction<IEntityBuilder<TEntity>>whereIEntityBuilder<TEntity>covers both publisher and subscriber configuration. Thewhere TEntity : classconstraint is required by the subscriber handler registration. Value types cannot be entity types.UseSerializer/UseCompressorat the global level forward the factory's output to the subscriber's global instance by callingfactory(typeof(object))— this works correctly when the factory ignores the type parameter (the common case). - Tracker as thin coordinator:
EntityChangeTrackercomposes aChangePublisher(required) and aChangeSubscriber?(optional) via constructor injection.ChangeTrackingBuilder.BuildInternal()creates theChangePublisher, registers all plugins on it, then builds the subscriber via_subscriberBuilder.Build()and passes both to theEntityChangeTrackerconstructor.PublisherandSubscriberareinternal; plugin assemblies (RayTree.EntityFrameworkCore,RayTree.Plugins.PostgreSQL) and test projects access them viaInternalsVisibleToentries inRayTree.Core.csproj. External callers use only the tracker's public surface (TrackXxxAsync,StartAsync,StopAsync,RunCleanupAsync,Meter). - Publisher loop lifetime:
OutboxPublisherServiceinstances are created and started inside the tracker'sinternal InitializeAsync(), called synchronously byBuild()(or asynchronously byBuildAsync()). Consumer loop lifetime is separate:tracker.StartAsync(CancellationToken)starts all shared and isolated loops;tracker.StopAsync()awaits them. In productionChangeTrackingHostedServicecalls these; in tests or standalone apps call them directly.ChangeTrackingHostedServiceis a thin adapter — no loop management logic of its own. - Reflection for generic dispatch:
OutboxPublisherService,NotificationBasedPublisher, andChangeSubscriberall useMethodInfo.MakeGenericMethodto invoke serializer/deserializer methods with the runtime entity type. Return types are declared as the non-generic base (Task<EntityChange>) so the async upcast works cleanly. - PostgreSQL outbox schema: Each entity type gets its own outbox table. Entity properties are stored as flat columns (not JSON), derived via
EntityColumnMapper.GetColumns(typeof(TEntity)). By default column names arestate_<snake_case>and the table name is<snake_case>_outbox. Both are customisable viaSystem.ComponentModel.DataAnnotations/Schemaattributes on the entity class — see theRayTree.Plugins.PostgreSQLplugin row above for the full attribute reference.EntityColumnMapper.GetTableName(Type)encapsulates the[Table]-aware table-name logic and is the single place bothPostgreSqlOutboxandPostgreSqlRepositoryuse for their defaults.EntityColumnMapper.ToPostgresType(Type)maps CLR types to PostgreSQL column types; 1D array properties (e.g.int[]) are emitted as the corresponding PostgreSQL array type (e.g.INTEGER[]) — see the plugin row above for the full mapping table.EntityColumnMapper.ConvertFromDb(object value, Type targetType)is the shared read-path helper used by bothPostgreSqlOutbox.ReadEntityChangeandPostgreSqlRepository.MapEntity; it short-circuits viaIsAssignableFrom(covering arrays and exact-type matches) before falling back toConvert.ChangeTypefor scalar numeric coercions.InitializeAsyncbranches on table existence: if the table does not exist, oneCREATE TABLE IF NOT EXISTScreates all columns and indexes; if it exists, column diff + index diff are applied (seeSchemaMigratorandIndexMigratorin the plugin row above). Three outbox indexes are declared in the schema:idx_*_outbox_unpublished— partial on(published, timestamp) WHERE published = FALSE, used byGetUnpublishedAsync;idx_*_outbox_cleanup— partial on(timestamp) WHERE published = TRUE, used byCleanupPublishedAsync;idx_*_outbox_entity— on(entity_type, published, timestamp), used by the filteredGetUnpublishedAsyncoverload.IndexMigrator.ApplyDiffAsynckeeps these in sync with the live database on every startup by comparing againstpg_indexcatalog data and applying DROP + CREATE for changed definitions. - PostgreSQL primary key resolution:
EntityColumnMapper.GetKeyProperties(Type)returns the ordered list of key properties for an entity. It first looks for properties annotated with[Key]; multiple[Key]properties form a composite key ordered by[Column(Order)]then by declaration order. If no[Key]is found it falls back to theIdconvention property. If neither exists it throwsInvalidOperationExceptionat construction time (fail-fast).PostgreSqlRepositoryuses key properties to build INSERT, WHERE (UPDATE/DELETE/SELECT), and the source-table UNIQUE index.IRepository<TEntity>.GetByIdAsynctakesobject[] keyValues— one value per key property in the same order. - Outbox rotation is part of the publisher loop, not a separate service:
OutboxPublisherService.MaybeRunCleanupAsyncruns inline after each batch in the same polling goroutine. It fires eagerly on the first tick (cleans up stale data from before startup), then gates subsequent runs onCleanupInterval._lastCleanupis only advanced when both cleanup operations succeed; a failure leaves_lastCleanupunchanged so the next tick retries immediately, giving operators fast feedback via repeated error logs rather than silently waiting a full interval. This keeps rotation within the tracker's lifecycle — no extra hosted service, no external scheduler. Cleanup errors are isolated with their own try/catch so a transient DB failure does not abort the publish loop. Rotation runs sequentially (not in parallel with publishing) because the cleanup DELETE and the unpublished SELECT target disjoint row sets (published = TRUEvspublished = FALSE) — no concurrency is gained, but isolation makes error handling straightforward. For ad-hoc manual rotation, calltracker.RunCleanupAsync(retentionPeriod, ct)directly. - NotificationBasedPublisher as a fast-path overlay: when
UseNotificationChannel = trueinPostgreSqlOutboxOptions, the DB trigger fires apg_notifyon everyINSERTinto the outbox table.NotificationBasedPublisherreceives that notification, atomically claims the record viaIOutbox.TryClaimForPublishingAsync(prevents races with other publishers), and publishes immediately. The fallback polling loop insideNotificationBasedPublisherruns only on the first tick (to drain records written before the listener was established) and when_listenerHealthy = false(LISTEN connection broke).OutboxPublisherServicecontinues running but atFallbackPollingIntervalcadence — it acts as a safety net for anything the notification path misses, not an equal peer. Both paths useTryClaimForPublishingAsync/RevertClaimAsyncto prevent duplicate publishing without relying solely on subscriber-side deduplication. - Dedup mark-before-process with revert-on-failure:
ChangeSubscribercallsTryMarkProcessedAsyncbefore invoking handlers (single round-trip, prevents concurrent duplicate processing). If a handler exhausts its retries and throws,RevertProcessedAsyncremoves the entry before the exception propagates, so the redelivered message is accepted and retried. WhenSkipOnFailure = true, no revert is issued — the intentional skip is permanent. This ensures at-least-once semantics are preserved even with persistent dedup stores (e.g., Redis) that survive process restarts. - Kafka thread safety:
KafkaConsumerkeeps a single backgroundTask.Runthread that owns allIConsumer<K,V>operations.Dispose()cancels via_disposeCts, waits up to2×PollTimeoutMs + 200 msfor the poll task to exit, then frees the native handle. - Integration tests use Testcontainers: PostgreSQL, Kafka, and RabbitMQ tests require Docker. Mark test classes
[NonParallelizable]when sharing a container. Use unique topic/queue names per test to avoid cross-test contamination. - Metrics placement rule — required meter, no null fallback:
RayTreeMeteris a required non-null constructor parameter on every runtime service that emits metrics (ChangePublisher,OutboxPublisherService,ChangeSubscriber). There is no internalnew RayTreeMeter()fallback in those classes — the builder layer (ChangeTrackingBuilder.BuildInternal) constructs a default meter when the caller didn't supply one viaUseMeter, then injects it everywhere. This matches the logging rule: callers make a conscious choice at builder/DI level, runtime services have no hidden defaults.EntityChangeTrackertracks ownership via anownsMeterflag and disposes the meter only when it created it; caller-supplied meters are left alone. Instrument calls are silent no-ops when no listener is attached, so opting out costs nothing at runtime. - OTel SDK isolation via peer assembly:
RayTree.CoreandRayTree.Hostinguse onlySystem.Diagnostics.Metrics(BCL) — noOpenTelemetry.*package references.RayTree.OpenTelemetryis a separate assembly with two members (RayTreeInstrumentation.MeterName+AddRayTreeMetrics) that an application opts into. Applications that don't need OTel receive zero transitive OTel dependencies; applications that do reference exactly one well-versioned dependency. This mirrors theRayTree.Hosting/RayTree.EntityFrameworkCoresplit. - Logging placement rule:
NullLoggerFactory.Instance/NullLogger<T>.Instancedefaults belong only in builders and builder-context extension methods (ChangeTrackingBuilder,ChangePublisherBuilder,ChangeSubscriberBuilder,KafkaSubscriberExtensions.UseKafka,RabbitMqBuilderExtensions.UseRabbitMq,RabbitMqSubscriberExtensions.UseRabbitMq,BuilderExtensions.UsePostgreSqlOutbox,RepositoryExtensions.UsePostgreSqlRepository). All runtime service classes (ChangePublisher,OutboxPublisherService,ChangeSubscriber,ChangeTrackingHostedService,KafkaConsumer,NotificationBasedPublisher,PostgreSqlOutbox<TEntity>,PostgreSqlRepository<TEntity>) require a non-nullable logger — no internal fallback.RabbitMqPublisherandKafkaPublisherboth accept an optionalILoggerFactory?(null →NullLoggerFactory.Instance) to support topology-wait / topic-wait logging without forcing every caller through DI; both fluent builder extensions (KafkaBuilderExtensions.UseKafka,KafkaSubscriberExtensions.UseKafka<TEntity>) accept an optionalILoggerFactory?parameter and forward it so the consumer-side probe — whose underlyingKafkaConsumerconstructor already requires a non-null logger factory — is actually reachable with a real factory from the documented fluent API (previously this extension hardcodedNullLoggerFactory.Instance, silencing every probe log). Exception:RabbitMqConsumerintentionally has no logger — message-receive errors silently NACK and requeue, which is the correct broker-level recovery; no useful context is available inside the RabbitMQ delivery callback to produce a meaningful log entry. Consumer-side topology-wait probes therefore passlogger: nulltoTopologyProbe, preserving the exception. This ensures that callers always make a conscious choice about whether to produce log output. - Configuration & lifecycle logs:
ChangeTrackingBuilderemitsInformationfor eachUse*registration (with{Plugin}= type name), anInformation"ChangeTracker built" summary atBuildInternaltime (with{EntityTypes},{Plugins},{HasCustomMeter},{HasCustomDeduplicationStore},{HasCustomLoggerFactory}), and aDebug"no meter supplied" note when it creates the defaultRayTreeMeter.ForEntity<TEntity>logsInformationfor the entity type; per-entity overrides (UseOutbox,UsePublisher,UseSerializer,UseCompressor,UseRepository,UseSubscriberOptions,UseConsumer,UseConsumerFactory,OnInsert/OnUpdate/OnDelete/OnChange) log atDebugwith{EntityType},{Override}(slot name like"Outbox"or"OnInsert:handlerName"), and{Plugin}(concrete type name).EntityChangeTracker.InitializeAsynclogsInformation"tracker initialization started" / "completed" bracketing twoDebugsub-step entries: "publisher initialized" with{EntityTypeCount}and "consumers initialized" with{ConsumerCount}; on failure it emits oneWarning"tracker initialization aborted" (no exception payload — the inner service's ownErrorcarries the cause) before rethrowing.ChangeTrackingHostedService.StartAsyncemits oneInformation"ChangeTracking starting" with{ConfigurationBound}(sourced from theChangeTrackingDiContextsingleton registered byAddChangeTracking). Every call is guarded byIsEnabled(...)soNullLoggerFactoryproduces zero allocations.
See AGENTS.md for all coding rules, design principles, testing conventions, nullability discipline, and AI agent workflow guidelines. Key .editorconfig conventions (naming, braces, using order) are summarized below; AGENTS.md is the canonical source.
- Private/internal fields:
_camelCase; static:PascalCase; constants:PascalCase - Expression-bodied members for single-expression members
usingoutside namespace; System namespaces first- Braces on a new line
- Use named params, especially with multiple args of the same type
.editorconfig wins over any conflicting suggestion.
.github/workflows/ci.yml has three job groups: build (compile gate, uploads compiled output as an artifact with 1-day retention), unit-tests (9-way parallel matrix, no Docker, downloads build artifact — no rebuild), integration-tests (3-way matrix: PostgreSQL / RabbitMQ / Kafka, also downloads build artifact). No job rebuilds the solution; all test jobs depend on the shared artifact from build.