Skip to content

Commit e420e35

Browse files
committed
feat: optional idempotency (ADR-0022) and per-URN schema validation (ADR-0024)
Opt-in, dependency-free helpers; wire envelope stays frozen. Includes the vendored payload_schema cross-SDK conformance cases.
1 parent 5c0e84f commit e420e35

11 files changed

Lines changed: 782 additions & 0 deletions

File tree

src/BabelQueue.Core/Idempotency.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
namespace BabelQueue;
2+
3+
/// <summary>
4+
/// A consume handler: processes one decoded <see cref="Envelope"/>. May be async; a
5+
/// thrown/faulted handler leaves the message unacknowledged so the runtime redelivers it
6+
/// (the core is codec-only, so an adapter drives the actual consume loop).
7+
/// </summary>
8+
public delegate Task Handler(Envelope envelope);
9+
10+
/// <summary>
11+
/// A pluggable record of message ids already processed, keyed on the envelope's
12+
/// <c>meta.id</c>. The reference <see cref="InMemoryStore"/> is for tests / single-process
13+
/// consumers; production backends (Redis, a database table) implement the same three
14+
/// methods. "Seen-set" post-success dedupe — not exactly-once, not in-flight locking; a
15+
/// transactional / outbox mode is a documented future direction (ADR-0022).
16+
/// </summary>
17+
public interface IIdempotencyStore
18+
{
19+
/// <summary>Whether this message id has already been processed (remembered).</summary>
20+
bool Seen(string messageId);
21+
22+
/// <summary>Records this message id as processed.</summary>
23+
void Remember(string messageId);
24+
25+
/// <summary>Drops an id from the store (manual eviction; a backend may also expire ids).</summary>
26+
void Forget(string messageId);
27+
}
28+
29+
/// <summary>
30+
/// Process-local, thread-safe <see cref="IIdempotencyStore"/> backed by a set. For tests
31+
/// and single-process consumers; not shared across workers and not persistent — use a
32+
/// Redis- or database-backed store for production fleets.
33+
/// </summary>
34+
public sealed class InMemoryStore : IIdempotencyStore
35+
{
36+
private readonly HashSet<string> _ids = new();
37+
private readonly object _gate = new();
38+
39+
/// <inheritdoc/>
40+
public bool Seen(string messageId)
41+
{
42+
lock (_gate)
43+
{
44+
return _ids.Contains(messageId);
45+
}
46+
}
47+
48+
/// <inheritdoc/>
49+
public void Remember(string messageId)
50+
{
51+
lock (_gate)
52+
{
53+
_ids.Add(messageId);
54+
}
55+
}
56+
57+
/// <inheritdoc/>
58+
public void Forget(string messageId)
59+
{
60+
lock (_gate)
61+
{
62+
_ids.Remove(messageId);
63+
}
64+
}
65+
}
66+
67+
/// <summary>
68+
/// Wraps a <see cref="Handler"/> so a message whose <c>meta.id</c> was already processed
69+
/// successfully is skipped (ADR-0022) — the .NET mirror of the PHP, Go, Python, and Node
70+
/// helpers. A previously-seen id returns early (so an adapter acks it); a thrown/faulted
71+
/// handler leaves the id unmarked so a redelivery runs it again; a message with no usable
72+
/// <c>meta.id</c> runs unchanged.
73+
/// </summary>
74+
public static class Idempotency
75+
{
76+
/// <summary>Returns <paramref name="handler"/> guarded by dedupe on <c>meta.id</c>.</summary>
77+
public static Handler Wrap(IIdempotencyStore store, Handler handler) =>
78+
async envelope =>
79+
{
80+
string? id = envelope.Meta?.Id;
81+
82+
// No usable id → cannot dedupe; run the handler unchanged.
83+
if (string.IsNullOrEmpty(id))
84+
{
85+
await handler(envelope).ConfigureAwait(false);
86+
return;
87+
}
88+
89+
// Already processed on an earlier delivery: return so the adapter acks it.
90+
if (store.Seen(id))
91+
{
92+
return;
93+
}
94+
95+
// First success wins; a throw here leaves the id unmarked → retry/DLQ apply.
96+
await handler(envelope).ConfigureAwait(false);
97+
store.Remember(id);
98+
};
99+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Collections.Generic;
2+
3+
namespace BabelQueue.Schema;
4+
5+
/// <summary>
6+
/// A source of per-URN <c>data</c> schemas, keyed on the message URN (ADR-0024). Returns the
7+
/// decoded JSON Schema for a URN's <c>data</c> block, or <c>null</c> when none is registered —
8+
/// in which case the caller skips validation (the feature is opt-in). The reference
9+
/// <see cref="MapProvider"/> is in-memory; the I/O-free core ships no file-based provider, so a
10+
/// .NET app reads its babelqueue-registry <c>registry.json</c> and passes the schemas in.
11+
/// </summary>
12+
public interface ISchemaProvider
13+
{
14+
/// <summary>The decoded JSON Schema registered for <paramref name="urn"/>, or null.</summary>
15+
IReadOnlyDictionary<string, object?>? SchemaFor(string urn);
16+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using BabelQueue;
2+
3+
namespace BabelQueue.Schema;
4+
5+
/// <summary>
6+
/// Raised when a message's <c>data</c> does not match the JSON Schema registered for its URN
7+
/// (ADR-0024). The consumer-side <see cref="SchemaValidation.Wrap"/> throws it so the adapter
8+
/// redelivers (and eventually dead-letters) a poison message; the recommended primary use is
9+
/// producer-side (<see cref="SchemaValidation.Validate"/>) so invalid data never enters the queue.
10+
/// </summary>
11+
public sealed class InvalidPayloadException : BabelQueueException
12+
{
13+
/// <summary>Create the exception for a URN whose data violated its schema.</summary>
14+
public InvalidPayloadException(string urn, string violation)
15+
: base($"Message data for \"{urn}\" does not match its URN schema: {violation}.")
16+
{
17+
Urn = urn;
18+
Violation = violation;
19+
}
20+
21+
/// <summary>The message URN whose schema was violated.</summary>
22+
public string Urn { get; }
23+
24+
/// <summary>The first <c>"&lt;json-pointer&gt;: &lt;reason&gt;"</c> mismatch.</summary>
25+
public string Violation { get; }
26+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Collections.Generic;
2+
3+
namespace BabelQueue.Schema;
4+
5+
/// <summary>In-memory <see cref="ISchemaProvider"/>, for tests and for embedding schemas in code.</summary>
6+
public sealed class MapProvider : ISchemaProvider
7+
{
8+
private readonly Dictionary<string, IReadOnlyDictionary<string, object?>> _schemas;
9+
10+
/// <summary>Build a provider from URN to already-decoded JSON Schema maps.</summary>
11+
public MapProvider(IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>> schemas)
12+
{
13+
_schemas = new Dictionary<string, IReadOnlyDictionary<string, object?>>(schemas);
14+
}
15+
16+
/// <summary>Build a provider from URN to raw JSON Schema strings, parsing each.</summary>
17+
public static MapProvider FromJson(IReadOnlyDictionary<string, string> raw)
18+
{
19+
var schemas = new Dictionary<string, IReadOnlyDictionary<string, object?>>();
20+
foreach (var entry in raw)
21+
{
22+
schemas[entry.Key] = SchemaJson.ParseObject(entry.Value);
23+
}
24+
25+
return new MapProvider(schemas);
26+
}
27+
28+
/// <inheritdoc/>
29+
public IReadOnlyDictionary<string, object?>? SchemaFor(string urn) =>
30+
_schemas.TryGetValue(urn, out var schema) ? schema : null;
31+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace BabelQueue.Schema;
5+
6+
/// <summary>
7+
/// Validates a message's <c>data</c> block against a per-URN JSON Schema (ADR-0024). A
8+
/// hand-rolled subset of Draft-07 (zero dependencies) whose verdicts match the Go, PHP,
9+
/// Python, Node and Java validators and babelqueue-registry's <c>compat</c> linter. Supported
10+
/// keywords: <c>type</c>, <c>required</c>, <c>properties</c>, <c>additionalProperties</c>,
11+
/// <c>items</c>, <c>enum</c>, <c>const</c>, <c>minLength</c>, <c>minimum</c>; unknown keywords
12+
/// are ignored. It works on the materialized <c>object?</c> tree the codec produces.
13+
/// </summary>
14+
public static class PayloadValidator
15+
{
16+
private static readonly IReadOnlyDictionary<string, object?> EmptyProps =
17+
new Dictionary<string, object?>();
18+
19+
/// <summary>
20+
/// The first violation of <paramref name="value"/> against <paramref name="schema"/> as
21+
/// <c>"&lt;json-pointer&gt;: &lt;reason&gt;"</c>, or <c>null</c> when it conforms.
22+
/// </summary>
23+
public static string? Validate(IReadOnlyDictionary<string, object?> schema, object? value) =>
24+
ValidateNode(schema, value, string.Empty);
25+
26+
private static string? ValidateNode(IReadOnlyDictionary<string, object?> schema, object? value, string path)
27+
{
28+
if (schema.TryGetValue("const", out var constValue) && !Equal(value, constValue))
29+
{
30+
return Violation(path, "wrong_const");
31+
}
32+
33+
if (schema.TryGetValue("enum", out var enumObj)
34+
&& enumObj is IReadOnlyList<object?> enumValues
35+
&& !Contains(enumValues, value))
36+
{
37+
return Violation(path, "not_in_enum");
38+
}
39+
40+
var type = schema.TryGetValue("type", out var t) && t is string s ? s : string.Empty;
41+
return type switch
42+
{
43+
"object" => CheckObject(schema, value, path),
44+
"array" => CheckArray(schema, value, path),
45+
"string" => CheckString(schema, value, path),
46+
"integer" => IsInteger(value) ? CheckMinimum(schema, value, path) : Violation(path, "not_an_integer"),
47+
"number" => IsNumber(value) ? CheckMinimum(schema, value, path) : Violation(path, "not_a_number"),
48+
"boolean" => value is bool ? null : Violation(path, "not_a_boolean"),
49+
"null" => value is null ? null : Violation(path, "not_null"),
50+
_ => null,
51+
};
52+
}
53+
54+
private static string? CheckObject(IReadOnlyDictionary<string, object?> schema, object? value, string path)
55+
{
56+
if (value is not IReadOnlyDictionary<string, object?> obj)
57+
{
58+
return Violation(path, "not_an_object");
59+
}
60+
61+
if (schema.TryGetValue("required", out var req) && req is IReadOnlyList<object?> required)
62+
{
63+
foreach (var key in required)
64+
{
65+
if (key is string name && !obj.ContainsKey(name))
66+
{
67+
return Violation(Join(path, name), "missing_required");
68+
}
69+
}
70+
}
71+
72+
var properties = schema.TryGetValue("properties", out var p)
73+
&& p is IReadOnlyDictionary<string, object?> props
74+
? props
75+
: EmptyProps;
76+
var additionalAllowed = !(schema.TryGetValue("additionalProperties", out var ap) && ap is false);
77+
78+
foreach (var member in obj)
79+
{
80+
if (properties.TryGetValue(member.Key, out var ps) && ps is IReadOnlyDictionary<string, object?> propSchema)
81+
{
82+
var found = ValidateNode(propSchema, member.Value, Join(path, member.Key));
83+
if (found is not null)
84+
{
85+
return found;
86+
}
87+
}
88+
else if (!additionalAllowed)
89+
{
90+
return Violation(Join(path, member.Key), "additional_not_allowed");
91+
}
92+
}
93+
94+
return null;
95+
}
96+
97+
private static string? CheckArray(IReadOnlyDictionary<string, object?> schema, object? value, string path)
98+
{
99+
if (value is not IReadOnlyList<object?> list)
100+
{
101+
return Violation(path, "not_an_array");
102+
}
103+
104+
if (!(schema.TryGetValue("items", out var it) && it is IReadOnlyDictionary<string, object?> items))
105+
{
106+
return null;
107+
}
108+
109+
for (var i = 0; i < list.Count; i++)
110+
{
111+
var found = ValidateNode(items, list[i], path + "[" + i + "]");
112+
if (found is not null)
113+
{
114+
return found;
115+
}
116+
}
117+
118+
return null;
119+
}
120+
121+
private static string? CheckString(IReadOnlyDictionary<string, object?> schema, object? value, string path)
122+
{
123+
if (value is not string str)
124+
{
125+
return Violation(path, "not_a_string");
126+
}
127+
128+
if (schema.TryGetValue("minLength", out var ml) && ml is long min && str.Length < min)
129+
{
130+
return Violation(path, "below_min_length");
131+
}
132+
133+
return null;
134+
}
135+
136+
private static string? CheckMinimum(IReadOnlyDictionary<string, object?> schema, object? value, string path)
137+
{
138+
if (schema.TryGetValue("minimum", out var m)
139+
&& TryDouble(m, out var min)
140+
&& TryDouble(value, out var actual)
141+
&& actual < min)
142+
{
143+
return Violation(path, "below_minimum");
144+
}
145+
146+
return null;
147+
}
148+
149+
// JSON numbers materialize to long (integers) or double (fractions); a whole-valued double
150+
// still counts as an integer, matching the other SDKs. bool is not long/double.
151+
private static bool IsInteger(object? value) => value switch
152+
{
153+
long or int => true,
154+
double d => !double.IsInfinity(d) && d == Math.Floor(d),
155+
_ => false,
156+
};
157+
158+
private static bool IsNumber(object? value) => value is long or int or double;
159+
160+
private static bool TryDouble(object? value, out double result)
161+
{
162+
switch (value)
163+
{
164+
case long l:
165+
result = l;
166+
return true;
167+
case int i:
168+
result = i;
169+
return true;
170+
case double d:
171+
result = d;
172+
return true;
173+
default:
174+
result = 0;
175+
return false;
176+
}
177+
}
178+
179+
// Type-aware equality: a long never equals a double, true never equals 1 — matching the
180+
// strict comparisons in the other SDK validators.
181+
private static bool Equal(object? a, object? b)
182+
{
183+
if (a is null || b is null)
184+
{
185+
return a is null && b is null;
186+
}
187+
188+
return a.GetType() == b.GetType() && a.Equals(b);
189+
}
190+
191+
private static bool Contains(IReadOnlyList<object?> values, object? value)
192+
{
193+
foreach (var item in values)
194+
{
195+
if (Equal(value, item))
196+
{
197+
return true;
198+
}
199+
}
200+
201+
return false;
202+
}
203+
204+
private static string Violation(string path, string reason) =>
205+
(path.Length == 0 ? "<root>" : path) + ": " + reason;
206+
207+
private static string Join(string path, string key) =>
208+
path.Length == 0 ? key : path + "." + key;
209+
}

0 commit comments

Comments
 (0)