This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
See AGENTS.md — canonical source for build/test commands, hard constraints (TreatWarningsAsErrors, nullable, package versioning), and every coding convention. Not duplicated here.
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>(PostgreSqlOutboxOptions, ILoggerFactory) — stores changes as flat columns (one per entity property via EntityColumnMapper). PostgreSqlRepository<TEntity>(PostgreSqlRepositoryOptions, ILoggerFactory) — source/key table, same attribute handling. Both params required on both constructors; builder extensions default ILoggerFactory? to NullLoggerFactory.Instance. EntityColumnMapper honours System.ComponentModel.DataAnnotations / Schema attributes: [NotMapped] excludes a property; [Column("name")] overrides the column suffix (state_ prefix always kept); [Column(TypeName = "JSONB")] sets the PostgreSQL type verbatim; [Required] forces NOT NULL; [MaxLength(n)]/[StringLength(n)] emits VARCHAR(n); [Table("name")] sets the base table name; [Key] (composite via [Column(Order = n)]) identifies the business primary key — used for INSERT/WHERE and the source-table UNIQUE index. 1D primitive arrays map to the matching PostgreSQL array type (int[] → INTEGER[], bool[] → BOOLEAN[], Guid[] → UUID[], DateTime[]/DateTimeOffset[] → TIMESTAMPTZ[], etc. — see EntityColumnMapper.ToPostgresType for the full mapping; nullable-element arrays strip the wrapper first); multi-dimensional arrays aren't supported. Cleanup deletes in batches (CleanupBatchSize, default 1000) to avoid large single-statement locks. InitializeAsync auto-migrates schema always (fresh CREATE TABLE, or column/index diff on an existing table — guards NOT NULL-without-default on non-empty tables by throwing; logs Warning for orphan columns/indexes/type mismatches). Three outbox indexes are kept in sync automatically: idx_*_outbox_unpublished (partial, published = FALSE), idx_*_outbox_cleanup (partial, published = TRUE), idx_*_outbox_entity (entity_type, published, timestamp). NotificationBasedPublisher — optional NOTIFY/LISTEN fast path (MaxConcurrentNotifications default 16) with polling fallback. Full schema-migration internals and connection-recovery detail: src/RayTree.Plugins.PostgreSQL/README.md. |
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. Default: envelope => $"{EntityType}:{EntityId}" — all changes for one entity land on the same partition, preserving per-entity ordering. Override to shard by any envelope field. Consumer uses a dedicated background thread 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 until handler success (Commit/Seek(TopicPartitionOffset) via an internal post-handler channel drained on the poll thread); requires SubscriberOptions.MaxDegreeOfParallelism = 1 per partition when enabled — commits are monotonic per partition, so concurrent workers could advance past an in-flight message. Parse failures always commit immediately to avoid poison-pilling the partition. Full delivery-guarantee mechanics: src/RayTree.Plugins.Kafka/README.md. Topic wait (opt-in, both options classes): WaitForTopic (default false), TopicWaitInterval (default 5 s), TopicWaitTimeout (default null — unlimited). When WaitForTopic = true, InitializeAsync probes the configured Topic via IAdminClient.GetMetadata and retries on transient not-yet-available conditions (empty Topics, UnknownTopicOrPart, LeaderNotAvailable, or a transient transport-level KafkaException) — the dominant startup-ordering case where the broker pod hasn't finished starting. Other errors propagate immediately. Each probe is bounded by KafkaTopicProbe.MetadataCallTimeout (1 s), decoupled from TopicWaitInterval. The publisher routes the probe through the lazy GetProducerAsync path (two separate semaphores so steady-state PublishAsync calls skip the probe once it's run); the consumer probes before allocating its native handle. Use this in microservice deployments where the topic owner pod starts after the consumer/publisher. Auto-create caveat: brokers with auto.create.topics.enable=true (common default) create the topic in response to the probe itself, masking real misconfiguration — set it false in deployments relying on this feature. |
RayTree.Plugins.RabbitMQ |
RabbitMqPublisher + RabbitMqConsumer. Routing key: RabbitMqPublisherOptions.RoutingKeySelector (Func<MessageEnvelope, string>) selects the AMQP routing key. Default: "{RoutingKey}.{EntityType}.{changeType}" (e.g. change.Order.insert) — consumers bind with wildcard patterns like change.Order.*. Set a custom delegate to route by tenant, aggregate root, or any envelope field. RabbitMqPublisher(RabbitMqPublisherOptions, ILoggerFactory?) — options required, logger factory optional. RabbitMqConsumer(RabbitMqConsumerOptions) — options only, no logger (message-receive errors silently NACK + requeue — no useful context is available at that point, an acknowledged exception to the logging-placement rule). RabbitMqConsumerOptions.AckAfterHandler (default false) defers the broker ACK until ChangeSubscriber confirms handler success (BasicAckAsync/BasicNackAsync(requeue: true), delivery tag carried in MessageEnvelope.Metadata). Topology wait (opt-in, both options classes): WaitForTopology (default false), TopologyWaitInterval (default 5 s), TopologyWaitTimeout (default null). When true, InitializeAsync probes externally-owned topology via AMQP passive declares and retries only on NOT_FOUND (404); other errors propagate immediately. Publisher probes when DeclareExchange = false; consumer probes when DeclareQueue = false (and the binding exchange when ExchangeName is set). Full mechanics, probe diagram, and logging table: src/RayTree.Plugins.RabbitMQ/README.md#topology-wait-microservice-deployments. |
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. Each recovery-owning plugin defines its own options type with the same six-field exponential-backoff shape (Enabled, InitialDelay, MaxDelay, Factor, JitterFraction, MaxAttempts): PostgresConnectionRecoveryOptions and KafkaConnectionRecoveryOptions. There is no shared Core options type and no Hosting binding — configure recovery per plugin in the UseKafka / UsePostgreSqlOutbox configure lambda.
- PostgreSQL —
NotificationBasedPublisher.ListenLoopAsyncrebuilds the LISTEN connection with backoff on a classified fault (PostgresFault.IsConnectionFault);OutboxPublisherService/ fallback polling only observe outbox faults (demote log level, no rebuild — pooled connections + polling cadence is the retry). Full detail: src/RayTree.Plugins.PostgreSQL/README.md#connection-recovery. - Kafka — publisher rebuilds its producer lazily on the next
PublishAsync(GetProducerAsyncunder_buildLock); consumer rebuilds inline on its dedicated poll thread (RebuildConsumer), bounded byKafkaConnectionRecoveryOptions. Full detail: src/RayTree.Plugins.Kafka/README.md (Error handling section). - RabbitMQ — observes only;
RabbitMQ.Client.AutomaticRecoveryEnabled(library default, not disabled) does the actual rebuild. Publisher logs shutdown/recovery events; consumer has no logger (design exception, see below) and is silent for recovery.RabbitMqPublisherOptions/RabbitMqConsumerOptionsdon't exposeConnectionRecovery— the library'sNetworkRecoveryIntervalcontrols timing.
The retry math (~20 LoC exponential backoff with jitter) is intentionally duplicated between Postgres and Kafka rather than extracted — a shared helper would need either InternalsVisibleTo exposure to plugin assemblies (architectural smell) or a public-API commitment to a specific retry shape. Two short copies is cheaper than either.
- 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, columns named
state_<snake_case>by default (see the plugin row above for the full attribute reference, table-naming, and array-type mapping).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. - 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, naming/.editorconfig conventions, and nullability discipline — canonical source, not duplicated here.
.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.