Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8be696c
feat(qwp): add persistent delta symbol dictionaries
kafka1991 Aug 14, 2026
679f2f6
fix: harden protocol compatibility and recovery
kafka1991 Aug 14, 2026
df41e3e
fix(qwp): gate poison strikes at the replay cursor and pace exempt NA…
kafka1991 Aug 14, 2026
5709b2d
fix(qwp): quarantine an unreplayable slot instead of failing every build
kafka1991 Aug 17, 2026
7898d77
test(e2e): pin symbol-dict survival across failover
kafka1991 Aug 17, 2026
46ad75a
fix(qwp): address code-review findings on delta symbol dictionaries
kafka1991 Aug 17, 2026
3867b8c
fix(qwp): harden slot lifecycle edges around delta symbol dictionaries
kafka1991 Aug 17, 2026
1e455a1
fix(qwp): keep a slot whose dictionary gap never replays out of quara…
kafka1991 Aug 17, 2026
937a5e6
fix(qwp): reclaim a cancelled row's symbol ids before publication
kafka1991 Aug 18, 2026
1fa78de
fix(qwp): reclaim aborted-row symbol ids on every row-cancel path
kafka1991 Aug 18, 2026
49b32d0
fix(qwp): classify pre-data NACKs by the pre-send watermark
kafka1991 Aug 18, 2026
0391488
fix(qwp): report an abandoned slot as data loss in the default error …
kafka1991 Aug 18, 2026
72102df
test(qwp): pin the symbol-dictionary mirror's delta-gap rejection
kafka1991 Aug 18, 2026
dcffe52
update review skill
kafka1991 Aug 18, 2026
2eaac55
fix(qwp): harden delta-dictionary recovery, durability, and reporting
kafka1991 Aug 18, 2026
ca84450
fix ent e2e tests
kafka1991 Aug 18, 2026
aba3208
fix(qwp): harden reconnect backoff, flush error typing, and trim orde…
kafka1991 Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 199 additions & 61 deletions .claude/skills/review-pr/SKILL.md

Large diffs are not rendered by default.

69 changes: 68 additions & 1 deletion src/net-questdb-client-tests/HttpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
using dummy_http_server;
using NUnit.Framework;
using QuestDB;
using QuestDB.Enums;
using QuestDB.Senders;
using QuestDB.Utils;

namespace net_questdb_client_tests;
Expand All @@ -39,6 +41,52 @@ public class HttpTests
private const int HttpPort = 29473;
private const int HttpsPort = 29474;

[Test]
public void ConstructorFailureAfterClientCreation_DisposesClient()
{
var options = new SenderOptions
{
protocol = ProtocolType.http,
protocol_version = (ProtocolVersion)int.MaxValue,
};
DisposalTrackingHandler? trackingHandler = null;

Assert.Throws<NotImplementedException>(() =>
_ = new HttpSender(options, _ =>
{
trackingHandler = new DisposalTrackingHandler();
return new HttpClient(trackingHandler, disposeHandler: true);
}));

Assert.That(trackingHandler, Is.Not.Null);
Assert.That(trackingHandler!.Disposed, Is.True,
"a constructor that fails after creating its HttpClient must release that client");
}

[Test]
public void BasicAuth_EncodesNonAsciiCredentialsAsUtf8()
{
var options = new SenderOptions
{
protocol = ProtocolType.http,
protocol_version = ProtocolVersion.V1,
username = "用户",
password = "пароль",
};
HttpClient? client = null;

using var sender = new HttpSender(options, handler =>
{
client = new HttpClient(handler, disposeHandler: false);
return client;
});

Assert.That(client, Is.Not.Null);
Assert.That(client!.DefaultRequestHeaders.Authorization?.Scheme, Is.EqualTo("Basic"));
Assert.That(client.DefaultRequestHeaders.Authorization?.Parameter,
Is.EqualTo(Convert.ToBase64String(Encoding.UTF8.GetBytes("用户:пароль"))));
}

[Test]
public async Task BasicArrayDouble()
{
Expand Down Expand Up @@ -2002,4 +2050,23 @@ await sender.Table("metrics")

await server.StopAsync();
}
}

private sealed class DisposalTrackingHandler : HttpMessageHandler
{
public bool Disposed { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
=> throw new NotSupportedException();

protected override void Dispose(bool disposing)
{
if (disposing)
{
Disposed = true;
}
base.Dispose(disposing);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,12 @@ public async Task FacadeDrainerListener_ReceivesDrainerEvents()
var orphanSlot = Path.Combine(root, "crashed-sibling");
using (var ring = QwpSegmentRing.Open(orphanSlot, segmentCapacity: 4096))
{
Assert.That(ring.TryAppend(new byte[] { 1 }), Is.True);
// Recovery validates orphan contents as QWP so the dictionary sidefile can be
// reconstructed safely; seed a legal zero-table commit frame, not opaque bytes.
var frame = QwpEncoder.Encode(
Array.Empty<QwpTableBuffer>(),
new QwpSymbolDictionary());
Assert.That(ring.TryAppend(frame), Is.True);
}

await using var server = await StartAckingServerAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,57 @@ public async Task Reconnect_WsCloseMidSession_NoSfDir_ReplaysFromRamRing()
await VerifyTableRowCountAsync("test_ws_close_ram", expected: 30, maxAttempts: 150);
}

[Test]
public async Task Reconnect_WsCloseMidSession_SymbolDictionaryCatchUpReplays()
{
await DropTableAsync("test_ws_close_symbols");
var realEndpoint = _questDb!.GetWebSocketEndpoint();
using var proxy = new TcpProxy(realEndpoint);
await proxy.StartAsync();

using (var sender = Sender.New(
$"ws::addr={proxy.LocalEndpoint};auto_flush=off;"
+ "reconnect_max_duration_millis=60000;"
+ "reconnect_initial_backoff_millis=50;reconnect_max_backoff_millis=500;"))
{
var qwp = (IQwpWebSocketSender)sender;

for (var i = 0; i < 10; i++)
{
sender.Table("test_ws_close_symbols")
.Symbol("sym", i % 2 == 0 ? "alpha" : "beta")
.Column("v", (long)i).At(DateTime.UtcNow);
}
await sender.SendAsync();
await qwp.PingAsync();

proxy.KillAllConnections();

// Post-kill rows reuse pre-kill symbol ids (restored by dictionary catch-up on the new
// connection) and introduce a new value that rides the post-reconnect delta.
for (var i = 10; i < 30; i++)
{
sender.Table("test_ws_close_symbols")
.Symbol("sym", i % 3 == 0 ? "gamma" : i % 2 == 0 ? "alpha" : "beta")
.Column("v", (long)i).At(DateTime.UtcNow);
}
await sender.SendAsync();

for (var i = 0; i < 200; i++)
{
try { await qwp.PingAsync(); break; }
catch { await Task.Delay(100); }
}
}

await VerifyTableRowCountAsync("test_ws_close_symbols", expected: 30, maxAttempts: 150);
var rows = await QueryAllRowsAsync(
"select sym, count() from test_ws_close_symbols order by sym");
Assert.That(
rows.Select(r => (r[0].GetString(), r[1].GetInt64())),
Is.EqualTo(new[] { ("alpha", 12L), ("beta", 12L), ("gamma", 6L) }));
}

[Test]
public async Task SchemaCacheReuse_TwoFlushesSameTable_BothLand()
{
Expand Down
50 changes: 50 additions & 0 deletions src/net-questdb-client-tests/Qwp/QwpFrameTestUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*******************************************************************************
* ___ _ ____ ____
* / _ \ _ _ ___ ___| |_| _ \| __ )
* | | | | | | |/ _ \/ __| __| | | | _ \
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
* \__\_\\__,_|\___||___/\__|____/|____/
*
* Copyright (c) 2014-2019 Appsicle
* Copyright (c) 2019-2026 QuestDB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
******************************************************************************/

using QuestDB.Qwp;

namespace net_questdb_client_tests.Qwp;

internal static class QwpFrameTestUtils
{
public static (int Start, string[] Entries) ReadSymbolDelta(byte[] frame)
{
var p = QwpConstants.HeaderSize;
var start = checked((int)QwpVarint.Read(frame.AsSpan(p), out var read));
p += read;
var count = checked((int)QwpVarint.Read(frame.AsSpan(p), out read));
p += read;

var entries = new string[count];
for (var i = 0; i < count; i++)
{
var len = checked((int)QwpVarint.Read(frame.AsSpan(p), out read));
p += read;
entries[i] = QwpStrictUtf8.Encoding.GetString(frame, p, len);
p += len;
}

return (start, entries);
}
}
2 changes: 2 additions & 0 deletions src/net-questdb-client-tests/Qwp/QwpResponseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public void StatusCodes_HaveStableByteValues()
Assert.That((byte)QwpStatusCode.WriteError, Is.EqualTo((byte)0x09));
Assert.That((byte)QwpStatusCode.Cancelled, Is.EqualTo((byte)0x0A));
Assert.That((byte)QwpStatusCode.LimitExceeded, Is.EqualTo((byte)0x0B));
Assert.That((byte)QwpStatusCode.NotWritable, Is.EqualTo((byte)0x0C));
Assert.That((byte)QwpStatusCode.DictionaryGap, Is.EqualTo((byte)0x0D));
}

[Test]
Expand Down
52 changes: 52 additions & 0 deletions src/net-questdb-client-tests/Qwp/QwpSymbolDictionaryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
******************************************************************************/

using NUnit.Framework;
using QuestDB.Enums;
using QuestDB.Qwp;
using QuestDB.Utils;

namespace net_questdb_client_tests.Qwp;

Expand All @@ -50,6 +52,34 @@ public void Add_RepeatedValue_ReturnsSameId()
Assert.That(d.Count, Is.EqualTo(2));
}

[Test]
public void Add_RefusesNewValuePastProtocolCapWithoutMutatingDictionary()
{
var d = new QwpSymbolDictionary(QwpConstants.MaxSymbolDictionarySize);
for (var i = 0; i < QwpConstants.MaxSymbolDictionarySize; i++)
{
d.Add("s" + i);
}
Assert.That(d.Count, Is.EqualTo(QwpConstants.MaxSymbolDictionarySize));

var ex = Assert.Throws<IngressError>(() => d.Add("one-too-many"));
Assert.That(ex!.code, Is.EqualTo(ErrorCode.InvalidApiCall));
Assert.That(ex.Message, Does.Contain(QwpConstants.MaxSymbolDictionarySize.ToString()));
Assert.That(ex.Message, Does.Contain("close this sender"));

Assert.That(d.Count, Is.EqualTo(QwpConstants.MaxSymbolDictionarySize));
Assert.That(d.Add("s42"), Is.EqualTo(42), "existing values remain usable at the cap");
Assert.Throws<IngressError>(() => d.Add("one-too-many".AsSpan()));
Assert.That(d.Count, Is.EqualTo(QwpConstants.MaxSymbolDictionarySize),
"a refused value must not mutate the dictionary");
}

[Test]
public void MaxSymbolDictionarySize_IsPinnedAtOrBelowServerProtocolLimit()
{
Assert.That(QwpConstants.MaxSymbolDictionarySize, Is.EqualTo(1_000_000));
}

[Test]
public void Add_NullString_MatchesEmptySpanAndDoesNotThrow()
{
Expand Down Expand Up @@ -151,6 +181,28 @@ public void RollbackTo_BelowCommittedWatermark_Throws()
Assert.Throws<ArgumentOutOfRangeException>(() => d.RollbackTo(0));
}

[Test]
public void AddRecovered_DuplicateAcrossRollback_NeverLeavesDanglingReverseMapping()
{
var d = new QwpSymbolDictionary();
Assert.That(d.AddRecovered("x"), Is.EqualTo(0), "recovery must not de-duplicate");
Assert.That(d.AddRecovered("x"), Is.EqualTo(1));
Assert.That(d.AddRecovered("y"), Is.EqualTo(2));

d.RollbackTo(2);
var idAboveDuplicate = d.Add("x");
Assert.That(idAboveDuplicate, Is.InRange(0, 1), "a live duplicate id must resolve, never the dropped one");
Assert.That(d.GetSymbol(idAboveDuplicate), Is.EqualTo("x"));

d.RollbackTo(1);
Assert.That(d.Count, Is.EqualTo(1));
Assert.That(d.GetSymbol(0), Is.EqualTo("x"), "the surviving duplicate keeps its value");
var idAfterRollback = d.Add("x");
Assert.That(idAfterRollback, Is.LessThan(d.Count),
"rolling back across a recovered duplicate must not leave the reverse lookup on a dead id");
Assert.That(d.GetSymbol(idAfterRollback), Is.EqualTo("x"));
}

[Test]
public void Reset_ClearsEverything()
{
Expand Down
Loading
Loading