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:
+52
-26
@@ -162,33 +162,59 @@ class PqlClient {
|
||||
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 {
|
||||
final ProcessResult r;
|
||||
try {
|
||||
r = await Process.run(
|
||||
toolchain.pql,
|
||||
args,
|
||||
workingDirectory: workDir.path,
|
||||
);
|
||||
} on ProcessException catch (e) {
|
||||
throw PqlException(
|
||||
'pql ${args.first}: ${e.message}',
|
||||
exitCode: e.errorCode,
|
||||
stderr: e.toString(),
|
||||
);
|
||||
for (var attempt = 1; attempt <= _kMaxAttempts; attempt++) {
|
||||
final ProcessResult r;
|
||||
try {
|
||||
r = await Process.run(
|
||||
toolchain.pql,
|
||||
args,
|
||||
workingDirectory: workDir.path,
|
||||
);
|
||||
} on ProcessException catch (e) {
|
||||
throw PqlException(
|
||||
'pql ${args.first}: ${e.message}',
|
||||
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();
|
||||
// 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) {
|
||||
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);
|
||||
// Unreachable: the loop returns, continues, or throws on the final attempt.
|
||||
throw StateError('pql retry loop exhausted without a result');
|
||||
}
|
||||
|
||||
/// Whether a non-zero pql exit looks like a transient db-busy / not-yet-ready
|
||||
/// condition worth retrying, vs. a genuine error to surface immediately.
|
||||
static bool _isTransient(int exitCode, String 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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user