Sparkdo.Messaging.EntityFrameworkCore 3.0.0-preview.1

This is a prerelease version of Sparkdo.Messaging.EntityFrameworkCore.
dotnet add package Sparkdo.Messaging.EntityFrameworkCore --version 3.0.0-preview.1
                    
NuGet\Install-Package Sparkdo.Messaging.EntityFrameworkCore -Version 3.0.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sparkdo.Messaging.EntityFrameworkCore" Version="3.0.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sparkdo.Messaging.EntityFrameworkCore" Version="3.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Sparkdo.Messaging.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sparkdo.Messaging.EntityFrameworkCore --version 3.0.0-preview.1
                    
#r "nuget: Sparkdo.Messaging.EntityFrameworkCore, 3.0.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sparkdo.Messaging.EntityFrameworkCore@3.0.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sparkdo.Messaging.EntityFrameworkCore&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Sparkdo.Messaging.EntityFrameworkCore&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Tool

Sparkdo.Messaging.EntityFrameworkCore

把「幂等标记」「待发消息」「Saga 状态」落进宿主自己的 DbContext,于是 「业务工作单元 + Inbox + Outbox 同一次提交」这件事真的成立——一次 SaveChanges 就是一次原子落地。

项 值
包 Sparkdo.Messaging.EntityFrameworkCore
交付框架 net11.0
外部 NuGet 依赖 Microsoft.EntityFrameworkCore + Microsoft.EntityFrameworkCore.Relational + Microsoft.Extensions.Diagnostics.HealthChecks
入口 modelBuilder.ConfigureSparkdoMessaging()(加模型)+ AddMessagingEntityFrameworkCore<TContext>(...)(登记端口)+ AddMessagingOutboxDispatcher()(起中继)+ AddMessagingPersistenceCleanup()(起清理,可选)+ AddMessagingOutboxHealthCheck()(健康检查,可选)
provider 不绑:模型里没有一句 provider 专有的东西(SQLite 与 PostgreSQL 都出证过)
AOT / 裁剪 不兼容——EF Core 自己就不兼容。引了它就等于放弃 AOT 单文件发布
<PackageReference Include="Sparkdo.Messaging.EntityFrameworkCore" Version="1.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="..." />

1. 接线(一条链,可整段照抄)

// ① 宿主自己的上下文:业务实体与消息表必须共用一个 DbContext
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.ConfigureSparkdoMessaging();   // 三张消息表加进这个模型
    }
}

// ② 四行接线
builder.Services.AddDbContext<AppDbContext>(b => b.UseNpgsql(connectionString));

builder.Services
    .AddSparkdoMessaging(serializers, bus => bus
        .AddTransport(new RabbitMQTransport(transportOptions), TransportDefault.Default)
        // ③ 外层先开工作单元,内层再判幂等:提交成功=标记落库
        .Use(WorkUnitMiddleware.Create(sp => sp.GetRequiredService<IWorkUnit>()))
        .Use(InboxMiddleware.Create(sp => sp.GetRequiredService<IMessageInbox>(), tenant: "orders", handlerId: "sparkdo.billing.order-placed-handler")))
    .AddMessagingEntityFrameworkCore<AppDbContext>()   // 只登记四个端口
    .AddMessagingOutboxDispatcher()                    // 显式起中继:只入队不投递的角色(Web 端)不调
    .AddMessagingPersistenceCleanup()                  // 显式起清理:保留期是部署决定,不调就不清
    .AddSparkdoMessagingHosting();

builder.Services.AddHealthChecks().AddMessagingOutboxHealthCheck();   // 可选:/health 里报中继在不在推进

宿主注册了多个上下文时用工厂重载挑一个:

builder.Services.AddMessagingEntityFrameworkCore(provider => provider.GetRequiredService<AppDbContext>());

处理器的登记必须是作用域形态,否则它拿不到本条投递的工作单元与上下文:

builder.AddScopedHandler<SettleOrderHandler, Settled>();      // 不是 AddHandler

三条顺序与重复调用的坑

坑 后果
先 AddSparkdoMessagingReliability() 再本方法 端口全被内存档占住:本包的 TryAdd 让位,跨进程语义静默失效,不报错。生产路径要么别调那一个,要么在它之后确认容器里是 EfCore*
忘了在 OnModelCreating 里调 ConfigureSparkdoMessaging() 解析端口时抛 InvalidOperationException 并点名这个扩展方法,不会拖到第一条消息才炸
中继忘了 AddMessagingOutboxDispatcher() 静默不投递:端口都在、发件箱照样入队,但没人取。"跑不跑中继"是部署决定,所以不做隐式自动化
AddMessagingOutboxDispatcher() 放在 AddMessagingEntityFrameworkCore 之前 当场抛错(先登记端口,再起中继,顺序不能反)
本方法调两次 端口只有一份(TryAdd);中继与清理也按实现类型去重,各只起一台。要在一个进程里跑两条投递路径,就别用托管登记,各自解析 IOutboxDispatcher 转进自己的循环
清理忘了 AddMessagingPersistenceCleanup() 三张表只进不出;卡住的在途行会一直判「在途」。生产库建议接上,不接就自己运维清(见 §4)
AddMessagingPersistenceCleanup() 放在 AddMessagingEntityFrameworkCore 之前 当场抛错(先登记端口,再起清理,顺序不能反)
IMessageSerializerRegistry 没接 入队要写 bytes,读写器缺失在建图期就抛(不会等到第一条消息)

2. 同事务的处理器长什么样

public sealed class SettleOrderHandler(
    AppDbContext db,
    WorkUnitAccessor workUnits,
    IMessageSerializerRegistry serializers,
    IMessageOutbox outbox) : IEventHandler<Settled>
{
    public async ValueTask HandleAsync(Settled message, CancellationToken ct)
    {
        var unit = workUnits.Current
            ?? throw new InvalidOperationException("这条投递没有工作单元:管道上没挂 WorkUnitMiddleware。");

        db.Orders.Add(new Order(message.OrderId, message.Amount));      // 业务写

        await outbox.EnqueueAsync(unit, BuildEnvelope(message), BuildBytes(message, serializers), ct);
    }
}
点 口径
谁开事务 WorkUnitMiddleware 在处理器之前取 IWorkUnit(本包给的是 scoped EfCoreWorkUnit),处理器返回后 CommitAsync,抛异常则 RollbackAsync
谁提交 CommitAsync = SaveChangesAsync + 提交事务。没调 SaveChanges 的更改本来就不会落库,幂等标记与发件箱行也在工作单元提交时才出现
入队 API 的形状 EnqueueAsync(IWorkUnit, Envelope, ReadOnlyMemory<byte>, CancellationToken)——没有泛型糖:信封与 bytes 由调用方自己拼(与传输边界同一口径:发件箱存的是线格式,不是 CLR 类型)
单元配对 传进来的工作单元不是本实现开的 → 当场拒绝(两者会落到不同事务)
载荷上限 超过 MaxPayloadBytes(默认 1 MiB)在入队时拒绝,不是中继时
幂等标记 由 InboxMiddleware 写,处理器不必自己动 Inbox 集合
断言时机 要等提交之后的状态:处理器进门就记一笔,而标记与发件箱行要到那条投递提交才落库。等错地方会断言到"还没提交"的中间态(本轮实测踩过)

Envelope 怎么拼(原样发布与发件箱同一份形状):

var envelope = new Envelope
{
    MessageId = MessageId.New(),
    MessageType = new MessageIdentity("urn:message:sparkdo.billing:settlement-notice"),
    ContentType = MessageContentType.Json,
    Destination = EndpointAddress.Parse("rabbitmq://topic/urn:message:sparkdo.billing:settlement-notice"),
    Source = EndpointAddress.Parse("rabbitmq://in/urn:message:sparkdo.billing:settled/sparkdo.billing.settle-order-handler"),
};

var buffer = new ArrayBufferWriter<byte>();
serializer.Write(buffer, notice);                    // 用登记过的那个读写器实例

outbox.EnqueueAsync(unit, envelope, buffer.WrittenMemory.ToArray(), ct);

3. 中继(发件箱 → 传输)

中继默认不跑:AddMessagingOutboxDispatcher() 才把它接进宿主生命周期(按实现类型去重,调几次都只起一台)。 接上之后宿主启动时它开始一轮轮取投,停机时收回去。要自己掌控节奏就解析 IOutboxDispatcher:

var dispatcher = provider.GetRequiredService<IOutboxDispatcher>();

var sent = await dispatcher.RunOnceAsync(ct);   // 跑一轮,返回发出几条
await dispatcher.RunAsync(ct);                  // 常驻循环:DispatcherInterval 一轮
事 语义
顺序 先发后标记:标记前崩溃只会重发(至少一次),反过来就是丢消息
认领 一批先 ClaimPendingAsync 认领到自己名下再发。裸扫表会让两台中继各投一遍;认领是带条件的 UPDATE,抢不到就是不发
暂时失败 当场 ReleaseAsync 放手,下一轮立刻能重来(至少一次)
永久拒绝 传输判 TransportFaultKind.Permanent 的行标死:写 DeadAt/DeadReason,此后不再认领、不重投(见下)
认领超时 DispatcherClaimTimeout(默认 5 分钟)兜"中继认领了行然后崩"。到期刻度存 UTC ticks 而不是时间戳——SQLite 不肯把 DateTimeOffset 的顺序比较推进 SQL,而这条判断必须留在 UPDATE 的 WHERE 里才挡得住抢
节奏 DispatcherInterval(默认 1 秒)一轮,DispatcherBatchSize(默认 64)一批;DispatcherMaxMessagesPerSecond(默认 0=不限)设了就在单轮内整流(见下)
一路失败 暂时失败每轮记一条警告(带连续失败次数),等待按指数退避增长:从 DispatcherInterval 起每轮翻倍,封顶 DispatcherMaxFailureBackoff(默认 30 秒),成功一轮清零
观察 IOutboxDispatcher.Status 快照 + 四个指标 + 可选健康检查(见下)
连续永久拒绝 到 PermanentRejectionBurstLimit(默认 10)就中止本轮并抛错,剩余行归还——地址/凭据写错不该一秒标死整批积压
与 hosted service 同一个实例 IOutboxDispatcher 是单例。别同时开两份(自己调 RunAsync 又让 hosted service 跑)

观察面与限流

中继把「在不在推进」摊成三份可读的东西:端口上的快照、核心 Meter 上的指标、可选的健康检查。

面 怎么读
快照 IOutboxDispatcher.Status:最近一轮/最近成功时刻、连续失败轮数、上一轮发出几条、累计发出/标死/失败轮数。每轮结束更新一次,读到的是一份只读副本
指标 订阅 Sparkdo.Messaging 一个源:messaging.outbox.sent.count(带 messaging.identity)、messaging.outbox.dead.count(同上)、messaging.outbox.failure.count(失败轮数)、messaging.outbox.round.duration(一轮毫秒)
健康检查 builder.Services.AddHealthChecks().AddMessagingOutboxHealthCheck() 接进宿主自己的 /health;默认名 sparkdo-messaging-outbox

健康检查判定只看快照:连续失败 ≥ DispatcherUnhealthyFailureThreshold(默认 5)→ 红;失败过但没到阈值 → 黄; 还没跑完第一轮 → 黄;其余 → 绿。data 里带积压数(现查 SentAtTicks 与 DeadAt 都为空的行数,2 秒超时); 数不出来只记 backlog = "count-failed",不把检查拖红——观测失败不是服务故障。

失败后的等待:第一轮失败等 DispatcherInterval,之后每轮翻倍,封顶 DispatcherMaxFailureBackoff(默认 30 秒), 再按 DispatcherFailureJitterRatio(默认 0.25)抖动;成功一轮立即回到 DispatcherInterval。抖动是为了不让多台中继 在同一刻重试、把恢复中的目的地再压垮一次。

限速可选:DispatcherMaxMessagesPerSecond 默认 0 表示不限。设了之后单轮内按最小间隔(1/速率)整流,跨轮不累计; 它治的是「一批 64 条一口气砸向目的地」的尖峰,不是令牌桶。每目标并发不做:发送天然串行,要并发就得改认领归属,属于另一个档位。

死信处置与重驱

标死不是销毁:行留在发件箱表里,SentAt 仍为空、DeadAt 非空,payload 原样在库。

面 口径
判定 发布抛 PublishRejectedException 且 Fault.Kind == TransportFaultKind.Permanent。分类由传输给出(RabbitMQ 的信道级协议错、Postgres 的 PostgresException 等判永久;连不上、IO、超时判暂时,未知一律暂时)
不判死 暂时故障、Canceled、分类缺失的异常一律归还重来——把停机竞态当成永久拒绝就是白丢一条消息
原因 DeadReason 存异常文案(超过 1024 字符截断),完整文案在当轮警告日志里
查 select "Id", "Identity", "EnqueuedAt", "DeadAt", "DeadReason" from sparkdo_messaging_outbox where "DeadAt" is not null order by "Id";
重驱 修好目的地之后用 IMessagingDeadLetterRedrive(容器里直接解析)把死信改回待发:清 DeadAt/DeadReason 与认领列,payload 与入队时刻保留,下一轮中继重新认领。示例见下表后
迁移 DeadAt/DeadReason 是新增可空列:宿主的迁移生成并执行之后,旧行自动为 null(照旧待发),无需回填
保留 死信不自动清:清理只删「已发行」行(SentAtTicks 非空),死信没有发行时刻,天然留在表里当证据

重驱是人工动作,不接后台循环;端口随 AddMessagingEntityFrameworkCore 一起登记,不调就一行都不动:

var redrive = provider.GetRequiredService<IMessagingDeadLetterRedrive>();

// 只放回这一条目的地的死信;一次最多 100 条(默认),按 Id 从旧到新。
var redriven = await redrive.RedriveAsync(new DeadLetterRedriveFilter
{
    Destination = "memory://in/urn:message:tests:order-placed",
});

// 想确认还剩多少,用上面的「查」再数一次。

DeadLetterRedriveFilter 三项取交集:Destination 与 Identity 留空即不限,MaxCount 默认 100、硬顶 1000。 每次重驱记一条 Info 日志,并在核心 Meter 上记一笔 messaging.outbox.redrive.count。

4. 清理与保留

三张表只进不出会一直长大,所以本包给「已经没用」的行一个出口。清理默认不跑: AddMessagingPersistenceCleanup() 才把它接进宿主生命周期(与中继同一体例,按实现类型去重)。

builder.Services
    .AddMessagingEntityFrameworkCore<AppDbContext>()
    .AddMessagingOutboxDispatcher()
    .AddMessagingPersistenceCleanup(options =>
    {
        options.InboxCompletedRetention = TimeSpan.FromDays(30);   // 大于本宿主的最大重投/重放窗口
        options.OutboxSentRetention = TimeSpan.FromDays(30);
    });
选项 默认 管什么
InboxCompletedRetention 7 天 幂等完成行(CompletedAtTicks 早于它)
StuckInboxTimeout 1 小时 卡住的在途行(CompletedAtTicks 为空且 StartedAtTicks 早于它)
OutboxSentRetention 7 天 已发行的发件箱行(SentAtTicks 早于它)
CompletedSagaRetention 7 天 已完成 Saga 行(CompletedAtTicks 早于它,且不在租约里)
BatchSize 500 一批删多少行
MaxBatchesPerRun 20 每类每轮最多删几批(首轮追平积压会跨多轮完成)
Interval 5 分钟 两轮之间隔多久

四类删除谓词:

类 谓词 为什么
幂等完成行 CompletedAtTicks < now - InboxCompletedRetention 完成标记过了保留期,重投已无意义
卡住的在途行 CompletedAtTicks is null and StartedAtTicks < now - StuckInboxTimeout 处理器崩在开始与完成之间,行会永久判「在途」,重投全被拦成 InboxInFlightException;删掉它,下一次重投才能重新开始
已发行发件箱行 SentAtTicks < now - OutboxSentRetention 发出去且过了保留期的 payload 只占空间
已完成 Saga 行 CompletedAtTicks is not null and CompletedAtTicks < now - CompletedSagaRetention and LeaseOwner is null 状态已走到终态;租约条件挡住刚被迟到事件认领、正在重演的行

不自动清的:

行 口径
死信(DeadAt 非空) 是证据,且可修好目的地后重驱(§3);SentAtTicks 为空,天然不命中「已发行」谓词
未发行/在途发件箱行 SentAtTicks 为空,不命中
没声明完成判据的 Saga 行 CompletedAtTicks 永远为 null:不命中第四类谓词,与今天的行为一致。声明 WhenCompleted 才有出口
没有 ticks 的旧行(升级前写入) 谓词只认 ticks 列,旧行为 null → 永不自动清;不回填也不会误删,回填见下
崩在释放租约前的完成行 写回成功后、ReleaseAsync 前进程崩掉:CompletedAtTicks 有值但租约还在(多半已过期)。第四类谓词只认 LeaseOwner is null,这行不会被自动清;手工收尾见下

清理是幂等的、可以多实例同跑(重复删除只影响计数);每批删除是原子的,取消时已删批次不回滚。 删除只记计数日志,不归档:需要审计的宿主在清理之前自己导出。

过期租约的完成行要人工收尾:把租约清掉,下一轮清理就会认它(示例是 Postgres;表名按宿主改):

update sparkdo_messaging_saga
set "LeaseOwner" = null, "LeaseExpiresAt" = null
where "CompletedAtTicks" is not null and "LeaseExpiresAt" < now();

别直接删:清租约之后仍走标准谓词,保留期判断一条不少。SQLite 把 DateTimeOffset 存成文本,比较口径按实际列类型写。

升级与回填

StartedAtTicks / CompletedAtTicks / SentAtTicks 都是新增可空列:宿主的迁移生成并执行后,旧行为 null。 Saga 的 CompletedAtTicks 列与 (CompletedAtTicks) 索引也在同一次迁移里;它没有可回填的来源(记的是判据命中的那一刻), 升级前写入的行一直为 null、永不自动清,等后续事件写回时按当时的终态补上。 想连旧行一起清就回填(示例是 Postgres 的 timestamptz 口径;621355968000000000 是 Unix 历元的 ticks 起点):

update sparkdo_messaging_inbox
set "StartedAtTicks" = (621355968000000000 + extract(epoch from "StartedAt") * 10000000)::bigint,
    "CompletedAtTicks" = case when "CompletedAt" is null then null
        else (621355968000000000 + extract(epoch from "CompletedAt") * 10000000)::bigint end
where "StartedAtTicks" is null;

发件箱同形(把表与列换成 sparkdo_messaging_outbox / SentAt / SentAtTicks)。回填用应用侧脚本 (DateTimeOffset.UtcTicks)最稳——SQL 里的历元换算要按 provider 的时区口径对齐,写错不如不回填。

保留期必须大于宿主的最大重投/重放窗口:删掉完成标记之后,一条老消息的再次投递会被当成新消费重新处理。 至少一次语义允许重复,但业务未必受得了第二次副作用。

5. 三张表

表(默认名) 键与索引 列要点
sparkdo_messaging_inbox 主键 Id;唯一索引 (Tenant, MessageId, HandlerId);清理索引 (CompletedAtTicks, StartedAtTicks) Tenant 256、HandlerId 512、MessageId Guid、StartedAt、CompletedAt?(null=在途)、StartedAtTicks?/CompletedAtTicks?(清理用 UTC ticks,旧行为 null)
sparkdo_messaging_outbox 主键 Id;索引 (SentAt, ClaimUntilTicks)、(SentAtTicks)(清理用) Identity 512、ContentType 256、Source/Destination 512 可空、Headers/Body 字节、MessageId、EnqueuedAt、SentAt?、SentAtTicks?、ClaimOwner? 512、ClaimUntilTicks?、DeadAt?、DeadReason? 1024(死信)
sparkdo_messaging_saga 主键 SagaId;索引 (CompletedAtTicks)(清理用) SagaType 512、State 字节、Version(状态版本戳,CAS 用)、LeaseOwner? 512、LeaseExpiresAt?、LeaseVersion、CompletedAtTicks?(完成标记,UTC ticks;没声明判据恒为 null)

默认表名就是 MessagingTables.Inbox/Outbox/Saga 三个常量。要换表名或 schema 不用覆盖 OnModelCreating, 在 ConfigureSparkdoMessaging 的委托里改(只认字母、数字、下划线),例如:

modelBuilder.ConfigureSparkdoMessaging(options =>
{
    options.Schema = "messaging";
    options.InboxTableName = "order_inbox";
    options.OutboxTableName = "order_outbox";
    options.SagaTableName = "order_saga";
});

schema 只在支持它的 provider 上出证(本仓是 Postgres 档);SQLite 上只改表名,不要设 schema——Saga 的原生 插入会带上 schema 前缀,SQLite 不认。

逐实体还能再定制(ConfigureInboxEntity/ConfigureOutboxEntity/ConfigureSagaEntity,在默认映射之后跑, 例如补列宽、加宿主自己的索引),整体再补用 ConfigureModel。内置列不能删、不能改类型——Saga 的原生插入 从模型元数据取表名与列名,删了列会在中继启动后第一次建行时抛错(列只能追加,且须可空或有默认值)。

迁移怎么给

模型在宿主的 OnModelCreating 里(ConfigureSparkdoMessaging 把三张表加进去),所以标准 dotnet ef migrations add 会连这三张表一起生成——不用给本包单独建迁移项目。用例走的是 Database.EnsureCreated():它只建默认 schema、不产生迁移历史,所以别拿它当交付路径; 测试里每次开一个新库而不是新 schema,正是这个原因。

6. 语义与边界(逐条实测)

面 语义
幂等三态 本上下文已跟踪的同键行 = 在途;库里已提交的行 = 已完成;都没有才插入开始标记(未提交,别的连接看不见)
并发重复 同一事务里读不到别人未提交的行,所以第二路会在提交时撞唯一索引 → 那次消费失败、重投时读到"已完成"
事务边界 构造时抓 context.Database.CurrentTransaction:有就并入,边界仍由打开事务的那一方决定(借来的那笔也由 CommitAsync 跟着提交);没有就自开一笔并记下所有权
释放路径 IWorkUnit.DisposeAsync 不碰上下文(释放路径上碰它会抛 ObjectDisposedException,实测过),只处置"自持且未提交"的那笔事务——自开的那笔没提交就回滚,"没提交就等于没发生"由数据库保证
Saga 争用 租约与状态版本戳都写在带条件 UPDATE 的 WHERE 里,影响 0 行即视为争用:返回"可重试",不静默覆盖
Saga 到期 到期判断在内存里做——SQLite 把 DateTimeOffset 存成文本,顺序比较推不进 SQL;抢占因此改按租约序号比对
Saga 读 一律 AsNoTracking:同一作用域里上下文还被 Inbox/Outbox 用着,跟踪器会把上一跳的旧行交回来
卡住的在途行 处理器崩在开始与完成之间会留下 CompletedAt 为空的行,之后重投一律判「在途」。接上 AddMessagingPersistenceCleanup() 后由「旧且未完成」谓词清扫(默认 1 小时);不接就仍要运维自己删那一行。设计文档 §10.4 的缺口按此处置
中继失败分类 永久拒绝标死(DeadAt/DeadReason),暂时失败与取消归还重投;连续永久拒绝到上限停轮。死信落在发件箱行上,不再只靠传输侧死信
中继观察 快照挂在端口上,指标挂核心 Sparkdo.Messaging 源,健康检查只读快照 + 现查积压且观测失败 fail open。死信深度、传输侧积压不在观察面内
中继节奏 单轮内按 DispatcherMaxMessagesPerSecond 整流(默认不限);失败退避从 DispatcherInterval 指数增长到 DispatcherMaxFailureBackoff,成功一轮清零。每目标并发不做:发送串行
清理与保留 四类删除谓词、七个选项与回填口径见 §4;清理是幂等的,多实例同跑只影响计数
Saga 完成标记 判据声明在 SagaDefinition.WhenCompleted;每次写回按新状态重算:完成写 UTC ticks,回落到未完成就清空(复活语义)。旧三参 SaveAsync 不碰标记;崩在释放租约前的完成行带过期租约,不自动清(§4 有手工 SQL)

7. provider 档位与出证

本包不绑 provider:三张表的映射里没有一句 provider 专有的东西,换 provider 只是把 UseSqlite(...) 换成 UseNpgsql(connectionString)。

出证方式:同一套语义用例换 provider 再跑一遍,开关是环境变量 SPARKDO_MESSAGING_POSTGRES (形如 Host=localhost;Port=5432;Database=sparkdo_messaging;Username=sparkdo;Password=sparkdo); 没设就跳过,跳过文案里带着这条形参。每条用例一个临时库(用完 drop database ... with (force)), 并行跑也不会互相看见——与 SQLite 档位一个临时文件同义。

# WSL 里起一台 Postgres(示例:系统包,端口让开 Windows 侧已占用的 5432)
sudo apt-get install -y postgresql
sudo sed -i 's/^port = 5432/port = 55432/' /etc/postgresql/*/main/postgresql.conf
sudo service postgresql restart
sudo -u postgres psql -c "create role sparkdo login password 'sparkdo' superuser createdb"

# Windows 侧跑用例
$env:SPARKDO_MESSAGING_POSTGRES='Host=localhost;Port=55432;Database=sparkdo_messaging;Username=sparkdo;Password=sparkdo'
dotnet test src/messaging/test/Sparkdo.Messaging.EntityFrameworkCore.Tests

读数:2026-10-19 在同一台库上复跑(WSL 内跑 Windows 产物)41/41、0 跳过。此前 2026-10-15 在 Postgres 18.6 上整工程 29/29(其中 4 条要真库)。端到端那几条把整摞一起跑: 总线 + Transport.Postgres + 本包——发布 → 序列化 → 库 → 占位取走 → 反序列化 → 工作单元与幂等中间件 → 处理器 → 结算,以及请求/响应(依赖临时订阅与回投地址,是这条链上最绕的一段)。

「传输过了契约」与「总线在这条传输上跑得通」是两件事:地址由核心拼、订阅按身份建、 幂等与工作单元按跳开作用域——这些都在传输边界之外,只有整摞跑过才算数。

从 Windows 连 WSL 里的库不稳;同一份产物在 WSL 里直接跑的跑法见 Sparkdo.Messaging.Transport.Postgres 的 README,把这个工程名换进去即可。 PostgresEvidenceTests 会显式断言 ProviderName == "postgres"——环境变量没生效却静默退回 SQLite 的话, 那套用例会全绿而证据是假的。

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0-preview.1 63 10/30/2026