Repository navigation
Description
Title
Test results (delay/speed/ip-info) are not persisted in ProfileExItem for new rows ΓÇö UNIQUE constraint failed: ProfileExItem.IndexId rolls back the whole flush batch
Environment
- v2rayN (ServiceLib) ΓÇö current master; introduced by PR Fix data race in ProfileExManager #10388 "Fix data race in ProfileExManager" merged 2026-10-17 (commit
673283d), persists after fix(profile-ex): 修复并发创建重复 ProfileExItem 的问题 #10420 - Feature context added in
0c796a1"Add IP info & flag emoji to test" (2026-05-20), which introduced a secondIndexIdEnqueueper successful real-ping
Summary
Since #10388, ProfileExManager.IndexIdEnqueue no longer dedupes (_queIndexIds plain → ConcurrentQueue), relying on the assumption "duplicates are harmless here since the save loop just writes the same row more than once".
That assumption is wrong for the INSERT path. SaveQueueIndexIds() classifies every queued id against a DB snapshot and puts unknown ids into lstInserts, then flushes with a single transactional InsertAllAsync(..., runInTransaction: true). A duplicate id for a not-yet-existing row means the second INSERT throws UNIQUE constraint failed: ProfileExItem.IndexId and sqlite-net RunInTransaction rolls back the entire batch ΓÇö every new row of that flush is silently lost (the exception is only logged by SaveTo's catch; the queue is already drained, so nothing is re-scheduled).
Duplicates are routine, not rare: every successful Real Delay writes the same IndexId twice into the flush window in SpeedtestService.DoRealPing (~lines 501ΓÇô516):
ProfileExManager.Instance.SetTestDelay(it.IndexId, responseTime); // enqueue #1
if (!_config.UiItem.HideColumnIpInfo && responseTime > 0)
{ var ipInfo = await ConnectionHandler.GetIPInfo(webProxy, ct);
ProfileExManager.Instance.SetTestIpInfo(it.IndexId, ipStr); // enqueue #2 ΓÇö same id
}plus the automatic retest of the failed part (RunRealPingBatchAsync(lstFailed, …) → second SetTestDelay for the same ids, SpeedtestService.cs ~274–287), SetTestMessage (:627), SetSort on import.
Conversely, a SaveTo() that runs while another flush is in progress (test-end flush at RunLoopAsync finally, 20-min TaskManager flush, exit flush at AppExitAsync) can classify an id as INSERT from two snapshots before either inserts it → same rollback.
User-visible symptom (verified on a real installation carrying upstream v7.25.6 code)
"Real Delay" values display during the session but disappear after an app restart. On that machine, ProfileExItem has 34 rows for 138 ProfileItems (75% missing), only 1 positive Delay, and its guiLogs contain repeating:
ProfileExHandler,UNIQUE constraint failed: ProfileExItem.IndexId
at SQLite.SQLiteConnection.InsertAll ⇒ RunInTransaction
at ServiceLib.Helper.SQLiteHelper.InsertAllAsync … SqliteHelper.cs:line 28
at ServiceLib.Manager.ProfileExManager.SaveQueueIndexIds() … ProfileExManager.cs:line 85
including failures at the exit flush. The timing explains why it went under the radar: only failed tests (Delay=-1, the IP-info write skipped → single enqueue) persist, which looks like "results recorded" until one notices inserted success values missing.
Repro:
- Fresh profile with no
ProfileExItemrow (orDELETE FROM ProfileExItem;with the app closed), - successful Real Delay test,
- restart the app → value gone,
- log shows the UNIQUE constraint failure at run end / exit flush.
Suggested fix
Make persistence idempotent instead of restoring dedupe (dedupe alone still allows overlapping concurrent flushes):
SqliteHelper.cs
public async Task<int> InsertOrReplaceAllAsync(IEnumerable models)
=> await _dbAsync.InsertAllAsync(models, runInTransaction: true, extra: "OR REPLACE").ConfigureAwait(false);ProfileExManager.SaveQueueIndexIds() ΓÇö drain into HashSet<string>, then upsert all touched rows:
var lstIds = new HashSet<string>();
while (_queIndexIds.TryDequeue(out var id))
if (id.IsNotEmpty()) lstIds.Add(id);
if (lstIds.Count > 0)
{
try
{
var lstUpserts = new List<ProfileExItem>();
foreach (var id in lstIds)
{
var itemNew = _lstProfileEx?.FirstOrDefault(t => t.IndexId == id);
if (itemNew is not null) lstUpserts.Add(itemNew);
}
if (lstUpserts.Count > 0)
await SQLiteHelper.Instance.InsertOrReplaceAllAsync(lstUpserts);
}
catch (Exception ex) { Logging.SaveLog(_tag, ex); }
}ProfileExItem has an explicit IndexId primary key (no autoincrement), so INSERT OR REPLACE is row-for-row correct and duplicates/overlapping flushes become harmless.
(I verified the pre-#10388 behavior: the older guard if (indexId.IsNotEmpty() && !_queIndexIds.Contains(indexId)) made plain INSERT safe, which is why results persisted before; a side-by-side old/new data folder on the same machine shows 250 persisted positive delays before vs 1 after.)
Activity
[-][Bug] delay/speed test results are not persisted for new ProfileExItem rows - duplicate IndexId enqueue + transactional INSERT rolls back the whole flush[/-][+][Withdrawn] - moved to patterniha/PattN#41 (nothing to do here)[/+][-][Withdrawn] - moved to patterniha/PattN#41 (nothing to do here)[/-][+][Bug] delay/speed test results are not persisted for new ProfileExItem rows - duplicate IndexId enqueue + transactional INSERT rolls back the whole flush[/+]