Library spike (S04)
Background for D-33, Q-ARCH-1, Q-DATA-1 and Q-DATA-2. Run on 2026-11-07 on linux-x64 (.NET SDK 10.0.403, Native AOT), against valkey-bundle (digest as in compose.test.yaml), Mosquitto 2, NATS 2.15 with JetStream, eclipse/zenoh:1.10.0 and ActiveMQ Artemis 2.42. One small AOT console project per library, then one combined project. The spike code was thrown away (spikes/ is not on main); the results that matter are below.
Pass criterion: dotnet publish -r linux-x64 with PublishAot and IL2026/IL3050 as errors, TrimmerSingleWarn off so that every warning shows.
Summary
| Library | Version | AOT publish | Runtime checks (AOT binary) |
|---|---|---|---|
| StackExchange.Redis | 3.4.31 | clean | Lua string scripts, Streams, XAUTOCLAIM, raw JSON.* and FT.*, reconnect |
| MQTTnet | 5.2.0.1603 | clean | v5 user properties, response topic, correlation, expiry, retained, shared subscription, manual ack |
| NATS.Net | 3.3.2 | clean | request/reply with headers, JetStream (dedupe, ack, redelivery), KV with CAS |
Kestrel minimal API + ModelContextProtocol.AspNetCore |
2.4.0 | clean | /healthz, MCP initialize, tools/list, tools/call over HTTP |
Zenoh-CS (sanri/zenoh-csharp) |
0.5.0 | clean (see caveat) | put/subscribe with attachment works; get/queryable crashes the process |
| AMQPNetLite.Core | 2.5.5 | clean (the AMQPNetLite metapackage: 13 warnings, 6 IL2070 and 7 IL3050, all in Amqp.Serialization) |
send and receive with a Data body, properties and application properties against Artemis |
| All except AMQP in one project (AMQP Core is clean on its own) | clean, 24.3 MB binary | /healthz, Redis connect, MQTT and NATS client creation, Config.Default() of Zenoh |
Single-project binary sizes (linux-x64, unstripped): Redis 9.0 MB, MQTTnet 7.5 MB, NATS 11.4 MB, Kestrel + MCP 17.3 MB, Zenoh-CS 2.1 MB.
StackExchange.Redis (D-7, Q-DATA-1, Q-DATA-2)
- Lua:
ScriptEvaluateAsync(string, RedisKey[], RedisValue[])works under AOT. A script that callsTIMEand then writes (HSET) is accepted.cjson.decodeis available in scripts. CAS on a version field works (stale write returns 0). - Streams:
StreamReadGroupAsync,StreamAcknowledgeAsyncandStreamDeleteAsyncas expected.StreamAutoClaimAsyncreturns the deleted ids typed (DeletedIds): with 5 pending entries and 2 deleted, it claimed 3 and reported 2 deleted. The rawXAUTOCLAIMreply has 3 elements (next id, entries, deleted ids). - Stream entry id as event time (D-18): the id's millisecond part equals server
TIMEwithin 1 ms (1794073650438-0against1794073650439). The proposal holds. - Polling versus nudge (D-7): 100 messages, random 20 to 60 ms apart, one consumer on one stream, reads of up to 10 entries:
| Strategy | p50 | p95 | max | server commands (100 messages) |
|---|---|---|---|---|
| poll 250 ms | 125 ms | 237 ms | 247 ms | 238 |
| poll 100 ms | 51 ms | 96 ms | 106 ms | 284 |
| poll 50 ms | 25 ms | 50 ms | 55 ms | 362 |
| pub/sub nudge, 1000 ms fallback poll | 1.0 ms | 1.7 ms | 2.3 ms | 503 (includes 100 PUBLISH) |
Latency of plain polling is about half the interval at the median and the interval at p95, so every hop of a step chain pays it. An idle poller costs one XREADGROUP per interval per consumer and stream (10 per second at 100 ms). The nudge removes the latency and keeps the poll as the fallback; its cost is one PUBLISH per XADD and a subscription per instance. Recommendation for S07: nudge plus a slow fallback poll (1 s or more), one poller component per instance.
* Documents (Q-DATA-1): 2000 documents, sequential round trips on localhost:
| Operation | valkey-json + valkey-search (raw Execute) |
hash {v, doc} + explicit sets (one Lua script) |
|---|---|---|
| write | JSON.SET 0.22 ms each |
Lua put with index maintenance 0.14 ms each |
find by JobOrderID |
FT.SEARCH 0.21 ms each |
GET index key, then HGET 0.29 ms (two round trips) |
| find by state | FT.SEARCH @state:{running} returned 667 of 2000 (correct) |
SMEMBERS returned 667 |
| read document | JSON.GET 0.12 ms |
HGET |
The flat-array FT.SEARCH reply ([total, key, ...]) parses without trouble through ExecuteAsync. A document was visible to FT.SEARCH immediately after JSON.SET (0 ms in this run; still poll with a timeout in tests). There is no performance reason to drop the modules. The remaining arguments are operational: a hard dependency on valkey-json and valkey-search (and their version drift, see Valkey findings) against a plain image and explicit indexes that the same script must keep consistent.
* Reconnect: killing Valkey raises ConnectionFailed; the command in flight fails at once with RedisConnectionException (SocketClosed); commands issued during the outage wait in the backlog and fail with a timeout after about 6 seconds if the server is still down; ConnectionRestored fires once the server accepts connections again (not timed precisely), and later commands succeed with no reconnect code in the application. Commands that fail are not retried by the library, so every write path must be idempotent under unknown outcome (R-4, S09).
* Admin commands (CONFIG RESETSTAT) need AllowAdmin = true; the production configuration does not.
MQTTnet 5.2
- All listed features work against Mosquitto 2: user properties (value is
ReadOnlyMemory<byte>; thestringoverload is obsolete), response topic, correlation data, content type, message expiry (an expired message was not delivered to a returning persistent session; the delivered one showed the remaining interval), retained messages (delivered to a late subscriber, cleared by an empty retained publish), shared subscription$share/g1/...(20 messages split 10/10 over two clients). - Manual ack: set
AutoAcknowledge = falsein the received-event arguments and callAcknowledgeAsynclater. - Persistent sessions need
WithSessionExpiryInterval; with the interval at 0 the session ends at disconnect even ifCleanSessionis false. - The payload is a
ReadOnlySequence<byte>.
NATS.Net 3.3
- Request/reply with headers, JetStream publish with
Nats-Msg-Id(duplicate detected on the second publish), explicit ack with redelivery afterAckWait(NumDelivered= 2), KVPutAsync/UpdateAsyncwith revision CAS (a stale revision raisesNatsKVWrongLastRevisionException). - Only
stringpayloads were used. Custom types need a source-generatedJsonSerializerContextregistered through the serializer registry; not measured.
Kestrel and MCP
WebApplication.CreateSlimBuilder,MapGet,MapMcp("/mcp"). Tool registration with the genericWithTools<T>(JsonSerializerOptions)overload and a source-generated context published without warnings and the tool was listed and called. The server ran in stateless mode (noMcp-Session-Idheader).- Not tested: the non-generic
WithTools(IEnumerable<McpServerTool>)registration, resources and prompts, and structured output types. The D-19 rule "explicit registration" holds for the generic overload; S11 verifies richer tools.
AMQPNetLite 2.5.5 (Q-ARCH-1)
- Corrected the same day. The first spike referenced the
AMQPNetLitemetapackage and saw 13 warnings, all inAmqp.Serialization(AmqpSerializer,MemberAccessor,MethodAccessor,AmqpContractResolver:MakeGenericType,DynamicMethod,GetMembers,GetInterfaces). They stayed with a plainDatabody because the metapackage brings the serializer assembly along. ReferencingAMQPNetLite.Core2.5.5 only: AOT publish with zero IL warnings (IsAotCompatibleon), 6.3 MB binary, and at runtime against Artemis a receiver attached, a message with properties and application properties was sent, received and accepted. - The serializer (object mapping with
[AmqpContract]) is the only AOT-hostile part and is not needed: CloudEvents map to message properties, application properties and aDatabody. RabbitMQ.AMQP.Client1.4.1 (Apache-2.0 AND MPL-2.0) depends onAMQPNetLite.Core2.5.4, so it should be clean as well; not tested.- Upstream (Azure/amqpnetlite) is active (2.5.5 on 2026-10-29, no open PRs). An AOT-compatible serializer would be a large change (it emits dynamic methods) and is not needed.
Zenoh-CS 0.5.0 and zenoh-c 1.10.0
- Native library: the official release
zenoh-c-1.10.0-<target>-standalone.zipexists forx86_64-unknown-linux-gnuandaarch64-unknown-linux-gnu. Its header definesZ_FEATURE_UNSTABLE_APIandZ_FEATURE_SHARED_MEMORY, so no custom zenoh-c build is needed.libzenohc.sois 17.9 MB (x64) and 16.8 MB (arm64) and must sit next to the binary (DllImport("zenohc")). Not run on arm64 in this session. - Binding shape: 617
DllImportdeclarations (80MarshalAs), callbacks through delegates marshalled withMarshal.GetFunctionPointerForDelegate. Thenetstandard2.0assembly carries no trim or AOT annotations, so a clean publish says nothing about safety; only runtime checks do. - Works in the AOT binary:
Session.Openagainstzenohd(client mode), publisherPutand subscriber callback with an attachment (ce_id=1;ce_type=tarrived intact, encodingzenoh/bytes). Three runs of publish/subscribe in a row closed cleanly. - Fails:
Session.Getwith a FIFO reply channel against a queryable crashes the process withfree(): invalid pointeratSession.Close()or at exit, with the reply received correctly before. It also happens with the queryable not replying, without payload and without attachment, in AOT and in JIT (in JIT it happened earlier, which points at a corrupted heap, not at AOT). A queryable that is only declared and closed is fine. - Hypothesis, not verified: the header of zenoh-c 1.10.0 ends
z_get_options_twithcancellation_token(underUNSTABLE_API); the binding'sZGetOptionsstruct stops attimeout_ms. zenoh-c would write 8 bytes past the allocation made fromMarshal.SizeOf. A patch could not be built in this session (the sandbox did not allow building the cloned third-party source), so neither the cause nor the fix is confirmed. Other structs may have drifted in the same way. - Consequence: the spot-check of P/Invoke declarations against the headers that the session block asks for has to cover every struct the processors use (get/query options, query reply options, publisher and subscriber options), not only function signatures. This stays an input to the Zenoh decision before S25 (contribute a fix upstream, fork, or ZenohDotNet), which this session does not make.
Reproduction (client mode against zenohd on tcp/localhost:7490):
var cfg = Config.FromStr("{mode:'client', connect:{endpoints:['tcp/localhost:7490']}}")!;
Session.Open(cfg, out var session);
session!.DeclareQueryable(Keyexpr.FromString("spike/cmd/*")!, new QueryableOptions(),
q => q.Reply(q.GetKeyexpr(), ZBytes.FromString("reply"), new QueryReplyOptions()), out var qb);
session.Get(Keyexpr.FromString("spike/cmd/x")!, new GetOptions { Timeout = 3000 }, null, ChannelType.Fifo, 8, out var ch);
ch!.Recv(out var reply);
session.Close(); // free(): invalid pointer
Not covered
- arm64 for any library (CI is the reference, D-32; zenoh-c arm64 is only checked as an existing release asset).
- ZenohDotNet (the fallback) was not tried; the Zenoh-CS result is mixed enough that S25 starts with it.
- Valkey pub/sub nudge under load with many scopes,
XAUTOCLAIMunder a real crash, Valkey cluster mode.