Sparkdo.Messaging.EntityFrameworkCore
3.0.0-preview.1
dotnet add package Sparkdo.Messaging.EntityFrameworkCore --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.Messaging.EntityFrameworkCore -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.Messaging.EntityFrameworkCore" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.Messaging.EntityFrameworkCore" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.Messaging.EntityFrameworkCore" />
paket add Sparkdo.Messaging.EntityFrameworkCore --version 3.0.0-preview.1
#r "nuget: Sparkdo.Messaging.EntityFrameworkCore, 3.0.0-preview.1"
#:package Sparkdo.Messaging.EntityFrameworkCore@3.0.0-preview.1
#addin nuget:?package=Sparkdo.Messaging.EntityFrameworkCore&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.Messaging.EntityFrameworkCore&version=3.0.0-preview.1&prerelease
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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- Microsoft.EntityFrameworkCore (>= 10.0.8)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.8)
- Microsoft.Extensions.Caching.Memory (>= 10.0.8)
- Microsoft.Extensions.DependencyInjection (>= 10.0.8)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.8)
- Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Logging (>= 10.0.8)
- Microsoft.Extensions.ObjectPool (>= 10.0.8)
- Sparkdo.Messaging (>= 3.0.0-preview.1)
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 |