pql: retry transient db-busy so sidebar panes don't stick (T-350)
The pql-backed sidebar panes fetch once on first build. If that fetch fired too early — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql exits 69) — the pane showed "pql … failed" and stayed there until a manual refresh re-fired it. Retry transient failures at the single chokepoint, PqlClient._run: on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy) retry a few times with short backoff before throwing. Genuine errors aren't busy, so they still surface immediately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3510,3 +3510,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
|||||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'status', 'backlog', 'done', NULL, '2026-06-10 16:57:07', '2026-06-10 16:57:07', '2026-06-10 16:57:07', NULL, '1ce291d54831d3ede687384037c379ff', 2) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB4XCM5KBXDDSCWJ37GPYG3R', 'status', 'backlog', 'done', NULL, '2026-06-10 16:57:07', '2026-06-10 16:57:07', '2026-06-10 16:57:07', NULL, '1ce291d54831d3ede687384037c379ff', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'status', 'backlog', 'done', NULL, '2026-06-10 17:13:53', '2026-06-10 17:13:53', '2026-06-10 17:13:53', NULL, '87b069d3f25c91622c9720147d359360', 2) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB50YE6S6YWNP2ZSFWES9B2W', 'status', 'backlog', 'done', NULL, '2026-06-10 17:13:53', '2026-06-10 17:13:53', '2026-06-10 17:13:53', NULL, '87b069d3f25c91622c9720147d359360', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'status', 'backlog', 'done', NULL, '2026-06-10 17:47:01', '2026-06-10 17:47:01', '2026-06-10 17:47:01', NULL, 'ba3d17ec4e668ade82b07d2bb848ab91', 2) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB58X0TFJ02YTMVPD0D9Q838', 'status', 'backlog', 'done', NULL, '2026-06-10 17:47:01', '2026-06-10 17:47:01', '2026-06-10 17:47:01', NULL, 'ba3d17ec4e668ade82b07d2bb848ab91', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'status', 'backlog', 'done', NULL, '2026-06-10 18:20:28', '2026-06-10 18:20:28', '2026-06-10 18:20:28', NULL, '213109fcd67b4375ebbd3b59c4a1ed04', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
|||||||
@@ -3213,3 +3213,9 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
|
|||||||
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression — the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
|
So a build host/container missing the Wayland client dev headers (very plausible in a Bazzite/immutable distrobox or toolbox) silently compiles out the decoration suppression — the rebuilt app ships with the compositor''s native title bar (double title bar on KDE Plasma Wayland). Reported live on Bazzite KDE.
|
||||||
|
|
||||||
Fix: make wayland-client a hard build requirement — fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:47:01', NULL, '6eb6120c0ef64a7f5ce78073b398143c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
Fix: make wayland-client a hard build requirement — fail the CMake configure with a clear, actionable message (name the package: Fedora wayland-devel, Debian/Ubuntu libwayland-dev) instead of dropping the feature. Frameless chrome is a core guardrail; never ship without it. Also fix the stale ''xdg-decoration'' comment (the code uses the KDE server-decoration protocol, not xdg-decoration).', 'done', 'high', NULL, NULL, NULL, '2026-06-10 17:45:28', '2026-06-10 17:47:01', NULL, '6eb6120c0ef64a7f5ce78073b398143c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
|
||||||
|
|
||||||
|
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:02:50', NULL, 'd03c864f5e64284f3582e59556a4ab4f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB5CW7JPT6BR2RWMNYVCXJ50', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'pql sidebar panes stick on error when the first fetch fires too early', 'The pql-backed sidebar panes (tickets, decisions, pql, search) fetch once on first build via pql.* IPC. If that first fetch hits a TRANSIENT pql failure — the planning DB still settling at startup, or a db-busy SQLite lock under concurrent pql writes (pql 1.10 exits 69 ''db busy'', as seen in the serial test suite) — the pane shows ''pql ticket failed'' and stays there until a manual refresh / tab-switch re-fires the fetch. Reproduced live on 2.3.0: left pane ''pql ticket failed''; works on manual refresh. pql works fine in isolation, so it''s purely a too-early / transient timing issue with no retry.
|
||||||
|
|
||||||
|
Fix: make pql invocations resilient to transient failures at the single chokepoint, PqlClient._run (lib/src/pql/client.dart) — on a busy/locked signal (exit 69, or stderr mentioning database is locked / busy), retry a small bounded number of times with short backoff before throwing PqlException. Keep genuine errors fast (don''t blanket-retry every non-zero). Fixes all pql panes at once.', 'done', 'high', NULL, NULL, NULL, '2026-06-10 18:02:50', '2026-06-10 18:20:28', NULL, 'd48c86ad83f3da7cd4f8c9308c852448', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **pql sidebar panes no longer stick on a transient startup error.** A
|
||||||
|
too-early or db-busy pql failure (the planning DB still settling, or a
|
||||||
|
SQLite lock under concurrent writes) is now retried a few times before
|
||||||
|
surfacing, instead of leaving the pane on "pql … failed" until a manual
|
||||||
|
refresh. (T-350)
|
||||||
|
|
||||||
## [2.3.0] — 2026-06-10
|
## [2.3.0] — 2026-06-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+52
-26
@@ -162,33 +162,59 @@ class PqlClient {
|
|||||||
return const {};
|
return const {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pql's exit code for a locked / unavailable planning DB (EX_UNAVAILABLE) —
|
||||||
|
/// a transient SQLite-busy condition under concurrent access (T-350).
|
||||||
|
static const int _kBusyExitCode = 69;
|
||||||
|
static const int _kMaxAttempts = 4;
|
||||||
|
|
||||||
Future<Object?> _run(List<String> args) async {
|
Future<Object?> _run(List<String> args) async {
|
||||||
final ProcessResult r;
|
for (var attempt = 1; attempt <= _kMaxAttempts; attempt++) {
|
||||||
try {
|
final ProcessResult r;
|
||||||
r = await Process.run(
|
try {
|
||||||
toolchain.pql,
|
r = await Process.run(
|
||||||
args,
|
toolchain.pql,
|
||||||
workingDirectory: workDir.path,
|
args,
|
||||||
);
|
workingDirectory: workDir.path,
|
||||||
} on ProcessException catch (e) {
|
);
|
||||||
throw PqlException(
|
} on ProcessException catch (e) {
|
||||||
'pql ${args.first}: ${e.message}',
|
throw PqlException(
|
||||||
exitCode: e.errorCode,
|
'pql ${args.first}: ${e.message}',
|
||||||
stderr: e.toString(),
|
exitCode: e.errorCode,
|
||||||
);
|
stderr: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final stderr = (r.stderr as String).trim();
|
||||||
|
// pql 1.5+ returns exit 0 with an empty `[]` for zero matches, so any
|
||||||
|
// non-zero exit is a real error (older pql used exit 2 for empty).
|
||||||
|
if (r.exitCode != 0) {
|
||||||
|
// A transient db-busy / still-settling failure — a sidebar pane firing
|
||||||
|
// its one-shot fetch too early at startup, or contention from
|
||||||
|
// concurrent pql writes — would otherwise stick until a manual refresh.
|
||||||
|
// Retry a few times with short backoff first. Genuine errors aren't
|
||||||
|
// busy, so they still surface immediately. (T-350)
|
||||||
|
if (attempt < _kMaxAttempts && _isTransient(r.exitCode, stderr)) {
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: 100 * attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw PqlException(
|
||||||
|
'pql ${args.first} failed',
|
||||||
|
exitCode: r.exitCode,
|
||||||
|
stderr: stderr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final stdout = (r.stdout as String).trim();
|
||||||
|
if (stdout.isEmpty) return null;
|
||||||
|
return jsonDecode(stdout);
|
||||||
}
|
}
|
||||||
final stderr = (r.stderr as String).trim();
|
// Unreachable: the loop returns, continues, or throws on the final attempt.
|
||||||
// pql 1.5+ returns exit 0 with an empty `[]` for zero matches, so any
|
throw StateError('pql retry loop exhausted without a result');
|
||||||
// non-zero exit is a real error (older pql used exit 2 for empty).
|
}
|
||||||
if (r.exitCode != 0) {
|
|
||||||
throw PqlException(
|
/// Whether a non-zero pql exit looks like a transient db-busy / not-yet-ready
|
||||||
'pql ${args.first} failed',
|
/// condition worth retrying, vs. a genuine error to surface immediately.
|
||||||
exitCode: r.exitCode,
|
static bool _isTransient(int exitCode, String stderr) {
|
||||||
stderr: stderr,
|
if (exitCode == _kBusyExitCode) return true;
|
||||||
);
|
final s = stderr.toLowerCase();
|
||||||
}
|
return s.contains('database is locked') || s.contains('db busy') || s.contains('database busy') || s.contains('locked');
|
||||||
final stdout = (r.stdout as String).trim();
|
|
||||||
if (stdout.isEmpty) return null;
|
|
||||||
return jsonDecode(stdout);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,4 +150,43 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('PqlClient — transient retry (T-350)', () {
|
||||||
|
late Directory tmp;
|
||||||
|
setUp(() async => tmp = await Directory.systemTemp.createTemp('clide_fakepql_'));
|
||||||
|
tearDown(() async {
|
||||||
|
if (await tmp.exists()) await tmp.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A fake `pql` whose body is [body]; a fresh `$0.n` counter file per test
|
||||||
|
// lets a script "recover" after N invocations.
|
||||||
|
Future<PqlClient> fakePql(String body) async {
|
||||||
|
final f = File('${tmp.path}/pql');
|
||||||
|
await f.writeAsString('#!/bin/sh\n$body\n');
|
||||||
|
await Process.run('chmod', ['+x', f.path]);
|
||||||
|
return PqlClient(workDir: Directory.current, toolchain: ToolchainView.resolved(ResolvedPaths(pql: f.path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a genuine (non-busy) error surfaces immediately', () async {
|
||||||
|
final p = await fakePql('exit 2');
|
||||||
|
await expectLater(p.files(), throwsA(isA<PqlException>().having((e) => e.exitCode, 'exitCode', 2)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a persistent db-busy (exit 69) throws after exhausting retries', () async {
|
||||||
|
final p = await fakePql('exit 69');
|
||||||
|
await expectLater(p.files(), throwsA(isA<PqlException>().having((e) => e.exitCode, 'exitCode', 69)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a transient db-busy (exit 69) recovers on retry', () async {
|
||||||
|
final p = await fakePql(r'c="$0.n"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; '
|
||||||
|
r'if [ "$n" -lt 3 ]; then exit 69; fi; echo "[]"');
|
||||||
|
expect(await p.files(), isEmpty); // retried through two busies to success
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a "database is locked" stderr (non-69 exit) is also retried', () async {
|
||||||
|
final p = await fakePql(r'c="$0.n"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; '
|
||||||
|
r'if [ "$n" -lt 3 ]; then echo "database is locked" >&2; exit 1; fi; echo "[]"');
|
||||||
|
expect(await p.files(), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user