Skip to content

Commit a3b300f

Browse files
Merge pull request #40 from Datasance/fix/sqlite-queue-self-recovery
Fix SQLite write-queue freeze with self-recovery and readiness fast p…
2 parents 7a85fb6 + c8ee832 commit a3b300f

13 files changed

Lines changed: 530 additions & 53 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
# Changelog
22

33

4-
## [v3.8.2] - 2026-07-25
4+
## [v3.8.2] - 2026-07-30
55

6-
Plan 21: liveness/readiness probe split and structured agent auth errors (coordinate with Edgelet v3.8.2). Also embedded OAuth logout/re-login hardening, EdgeOps Console **v1.0.12**, and operator sizing docs.
6+
Plan 21: liveness/readiness probe split and structured agent auth errors (coordinate with Edgelet v3.8.2). Also embedded OAuth logout/re-login hardening, EdgeOps Console **v1.0.12**, operator sizing docs, and **SQLite write-queue self-recovery** for long-running single-node deployments.
77

88
### Added
99

1010
- **`GET /api/v3/live`** — public **liveness** probe (process up; always 200 while HTTP server listens).
1111
- Structured agent fog JWT errors on **`/api/v3/agent/*`**: **`code`** + **`retryable`** on 401 (credential failure) and 503 (Controller dependency failure).
1212
- **`docs/operations/sizing.md`** — hardware sizing guide by fog count (Kubernetes and Remote ControlPlane).
13+
- **SQLite transaction recovery settings** — `settings.dbWriteQueueBackpressureDepth` (default **32**), `settings.dbTransactionTimeoutReadinessMs` (**5000**), `settings.dbTransactionTimeoutInteractiveMs` (**15000**), `settings.dbTransactionTimeoutBackgroundMs` (**120000**); env overrides **`DB_WRITE_QUEUE_BACKPRESSURE_DEPTH`**, **`DB_TRANSACTION_TIMEOUT_*_MS`** (see `docs/operations/database-transactions.md`).
14+
- **OTEL DB metrics** — `db.transaction.timeouts` and `db.write_queue.background_shed` for queue surgery and stuck-transaction visibility.
1315

1416
### Changed
1517

@@ -20,9 +22,15 @@ Plan 21: liveness/readiness probe split and structured agent auth errors (coordi
2022
- Embedded OAuth BFF authorize sends **`prompt=login`** so each browser sign-in starts a fresh issuer interaction.
2123
- **`POST /api/v3/user/logout`** (embedded) clears issuer Session/Grant/Interaction state and destroys the BFF express-session when present (refresh-token revocation unchanged).
2224
- Dependency bumps: OpenTelemetry **0.221.x**, **`body-parser` 1.20.6**, **`js-yaml` 4.3.0**, **`undici` ^7.28.0**; Dockerfile base image digest pins refreshed.
25+
- **SQLite write queue backpressure** — when total queued depth exceeds **`dbWriteQueueBackpressureDepth`**, new **background** enqueue is rejected (`QueueBackpressureError` → **503** when surfaced through agent auth); **`dbWriteQueueMaxDepth`** (**256**) remains alert-only.
26+
- **SQLite transaction timeouts** — per-lane timeouts abort stuck work, recycle the sqlite pool, and allow the queue worker to continue (interactive **15s**, background **120s**).
27+
- **`checkFogToken` transaction scope** — agent handler runs inside the auth transaction via **`runWithTransactionContext`**, so nested **`runInTransaction()`** reuses the parent writer instead of enqueueing a second sqlite transaction.
28+
- **Readiness database probe (SQLite)** — **`SELECT 1`** for **`GET /api/v3/status`** runs outside the global write queue with a **5s** timeout, so health checks stay responsive under write-queue pressure.
2329

2430
### Fixed
2531

32+
- **SQLite ControlPlane freeze after multi-day uptime** — a hung sqlite transaction could block the global write queue indefinitely (queue depth > **256**, console/potctl and **`/api/v3/status`** unresponsive until restart). Self-recovery: transaction timeouts, pool recycle, and background queue shedding under sustained backpressure.
33+
- **Agent auth nested-transaction deadlock (SQLite)** — **`checkFogToken`** authenticated in one transaction then invoked the handler outside ALS, allowing nested writes to deadlock the single-connection pool on hot agent routes (e.g. status/config polling).
2634
- **Secret PATCH** — omitted **`type`** in the update body now defaults to the existing secret type instead of **400** `Secret type mismatch` (fixes JSON PATCH and YAML secret updates that send only **`data`**).
2735
- **Embedded OAuth re-login after logout** — stale issuer session could yield **`access_denied`** on callback; logout now tears down issuer/BFF OAuth state and authorize forces fresh login.
2836
- **OAuth callback errors** — issuer errors (e.g. **`access_denied`**) redirect to **`{consoleUrl}/login?oauthError=...`** instead of **401 JSON** on the API port.

‎docs/operations/database-transactions.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ Phased reconcile and grep gates complement ALS: short txs for NATS/platform phas
8585
|---------|---------|--------------|
8686
| `settings.sqliteEnterpriseFogWarningThreshold` | 50 | `SQLITE_ENTERPRISE_FOG_WARNING_THRESHOLD` |
8787
| `settings.dbWriteQueueMaxDepth` | 256 | `DB_WRITE_QUEUE_MAX_DEPTH` |
88+
| `settings.dbWriteQueueBackpressureDepth` | 32 | `DB_WRITE_QUEUE_BACKPRESSURE_DEPTH` |
89+
| `settings.dbTransactionTimeoutReadinessMs` | 5000 | `DB_TRANSACTION_TIMEOUT_READINESS_MS` |
90+
| `settings.dbTransactionTimeoutInteractiveMs` | 15000 | `DB_TRANSACTION_TIMEOUT_INTERACTIVE_MS` |
91+
| `settings.dbTransactionTimeoutBackgroundMs` | 120000 | `DB_TRANSACTION_TIMEOUT_BACKGROUND_MS` |
8892
| `settings.dbBusyRetryMaxAttempts` | 8 | `DB_BUSY_RETRY_MAX_ATTEMPTS` |
8993
| `settings.dbBusyRetryBaseMs` | 25 | `DB_BUSY_RETRY_BASE_MS` |
9094
| `settings.reconcileOutboxDrainerIntervalSeconds` | 1 | `RECONCILE_OUTBOX_DRAINER_INTERVAL_SECONDS` |
@@ -97,7 +101,19 @@ See `src/config/config.yaml` and [architecture.md](../architecture.md) for pool
97101

98102
## SQLite write queue backpressure
99103

100-
When total queued work (`interactive` + `background` lanes) exceeds `settings.dbWriteQueueMaxDepth` (default **256**), Controller logs an **error** once per overflow episode. **Interactive requests are not rejected** — the queue continues to drain in priority order. Operators should investigate background job pressure or migrate to mysql/postgres.
104+
When total queued work (`interactive` + `background` lanes) exceeds `settings.dbWriteQueueBackpressureDepth` (default **32**), **new background enqueue attempts are rejected** with `QueueBackpressureError` (503 to callers when surfaced through agent auth). Interactive work continues to enqueue and drain in priority order.
105+
106+
When depth exceeds `settings.dbWriteQueueMaxDepth` (default **256**), Controller logs an **error** once per overflow episode. Interactive requests are still not rejected at the alert threshold — operators should investigate background job pressure or migrate to mysql/postgres.
107+
108+
### Self-recovery (SQLite)
109+
110+
| Layer | Trigger | Action |
111+
|-------|---------|--------|
112+
| **L1 timeout** | Transaction exceeds lane timeout | Reject with `TransactionTimeoutError`; worker continues |
113+
| **L2 pool recycle** | After interactive/background timeout | Close and re-init sqlite connection pool |
114+
| **L3 queue surgery** | 3+ interactive timeouts in 60s **or** backpressure sustained 30s | Shed all queued background tasks with `QueueBackpressureError` |
115+
116+
Readiness probes (`SELECT 1` for `/api/v3/status`) run **outside** the global write queue on SQLite so health checks stay responsive when the queue is wedged. Timeout defaults to `dbTransactionTimeoutReadinessMs` (5s); failures return **503** with `Retry-After`.
101117

102118
---
103119

@@ -110,6 +126,8 @@ Instruments are registered at startup in `src/helpers/db-metrics.js` (requires `
110126
| `db.transaction.duration` | histogram | `label`, `priority`, `provider` | p99 spike correlated with load |
111127
| `db.write_queue.depth` | gauge | `priority` | **> 100 for 5 min** → investigate background pressure |
112128
| `db.write_queue.wait_ms` | histogram | `priority` | Sustained high wait → scale DB or reduce background load |
129+
| `db.transaction.timeouts` | counter | `label`, `priority` | **Any sustained rate** → stuck tx or load spike |
130+
| `db.write_queue.background_shed` | counter | `reason` | **Any increment** → queue surgery fired; check stuck writers |
113131
| `db.busy_retries` | counter | `label` | **> 10/min** → lock contention |
114132
| `db.connection.invalidated` | counter | `provider` | **Any increment** → investigate pool / connection errors |
115133
| `db.sqlite.fog_count_warning` | counter | — | Fleet exceeded sqlite recommended size |
@@ -202,6 +220,7 @@ npm test -- --grep "grep gates"
202220
| **Fog platform phased reconcile** | Separate `fogPlatform.*` labels; no monolithic `fogPlatform.natsEnsure` |
203221
| **OIDC adapter** | All adapter reads/writes through `runInTransaction` with `oidc.adapter.*` labels |
204222
| **JTI cleanup** | `fog-token-cleanup-job.js` routes through `runInTransaction`, not bare manager call |
223+
| **Agent auth ALS** | `checkFogToken` runs the handler inside `runWithTransactionContext` so nested `runInTransaction` reuses the auth tx |
205224

206225
When a gate fails, fix the **minimal** violation (pass parent `transaction`, move I/O outside the tx body, or split phases) — do not disable the gate.
207226

‎src/config/config.yaml‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,15 @@ settings:
9898
jobStartupDelaySeconds: 3 # Delay before reconcile-heavy background jobs start (default: 3)
9999
reconcileOutboxDrainerIntervalSeconds: 1 # Outbox drainer poll interval in seconds (default: 1)
100100
reconcileOutboxDrainerBatchSize: 32 # Max outbox rows per drainer batch (default: 32)
101-
wsSessionReconcileIntervalSeconds: 60 # WS exec/log stale DB row reconcile interval (R89)
102-
sqliteEnterpriseFogWarningThreshold: 50 # Log + metric when sqlite fog count exceeds (R124)
103-
dbWriteQueueMaxDepth: 256 # SQLite write queue depth before error log (R123)
104-
dbBusyRetryMaxAttempts: 8 # SQLITE_BUSY retry attempts per transaction (Plan 19)
105-
dbBusyRetryBaseMs: 25 # Exponential backoff base for busy retry (Plan 19)
101+
wsSessionReconcileIntervalSeconds: 60 # WS exec/log stale DB row reconcile interval
102+
sqliteEnterpriseFogWarningThreshold: 50 # Log + metric when sqlite fog count exceeds
103+
dbWriteQueueMaxDepth: 256 # SQLite write queue depth before error log
104+
dbWriteQueueBackpressureDepth: 32 # Shed background enqueue when total queue depth exceeds (sqlite)
105+
dbTransactionTimeoutReadinessMs: 5000 # Readiness SELECT 1 timeout (sqlite, outside write queue)
106+
dbTransactionTimeoutInteractiveMs: 15000 # Interactive sqlite transaction timeout
107+
dbTransactionTimeoutBackgroundMs: 120000 # Background sqlite transaction timeout
108+
dbBusyRetryMaxAttempts: 8 # SQLITE_BUSY retry attempts per transaction
109+
dbBusyRetryBaseMs: 25 # Exponential backoff base for busy retry
106110

107111
# Database Configuration
108112
database:

‎src/config/env-mapping.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ module.exports = {
8080
RECONCILE_OUTBOX_DRAINER_BATCH_SIZE: 'settings.reconcileOutboxDrainerBatchSize',
8181
SQLITE_ENTERPRISE_FOG_WARNING_THRESHOLD: 'settings.sqliteEnterpriseFogWarningThreshold',
8282
DB_WRITE_QUEUE_MAX_DEPTH: 'settings.dbWriteQueueMaxDepth',
83+
DB_WRITE_QUEUE_BACKPRESSURE_DEPTH: 'settings.dbWriteQueueBackpressureDepth',
84+
DB_TRANSACTION_TIMEOUT_READINESS_MS: 'settings.dbTransactionTimeoutReadinessMs',
85+
DB_TRANSACTION_TIMEOUT_INTERACTIVE_MS: 'settings.dbTransactionTimeoutInteractiveMs',
86+
DB_TRANSACTION_TIMEOUT_BACKGROUND_MS: 'settings.dbTransactionTimeoutBackgroundMs',
8387
DB_BUSY_RETRY_MAX_ATTEMPTS: 'settings.dbBusyRetryMaxAttempts',
8488
DB_BUSY_RETRY_BASE_MS: 'settings.dbBusyRetryBaseMs',
8589

‎src/decorators/authorization-decorator.js‎

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const Errors = require('../helpers/errors')
55
const CODES = require('../helpers/agent-auth-error-codes')
66
const { classifyCheckFogTokenFailure } = require('../helpers/agent-auth-error-utils')
77
const { isTest } = require('../helpers/app-helper')
8-
const { runInTransaction } = require('../helpers/transaction-runner')
8+
const { runInTransaction, runWithTransactionContext, PRIORITY_INTERACTIVE } = require('../helpers/transaction-runner')
99

1010
function checkFogToken (f) {
1111
return async function (...fArgs) {
@@ -53,28 +53,21 @@ function checkFogToken (f) {
5353
throw new Errors.AgentAuthenticationError(CODES.AGENT_JWT_MISSING_SUBJECT, 'Agent JWT missing subject claim')
5454
}
5555

56-
const fog = await runInTransaction(async (transaction) => {
56+
return runInTransaction(async (transaction) => {
5757
const foundFog = await FogManager.findOne({ uuid: fogUuid }, transaction)
5858
if (!foundFog) {
59-
return null
59+
throw new Errors.AgentAuthenticationError(CODES.AGENT_FOG_NOT_FOUND, 'Agent fog not found')
6060
}
6161

6262
await FogKeyService.verifyJWT(token, fogUuid, transaction)
6363

6464
const timestamp = Date.now()
6565
await FogManager.updateLastActive(foundFog.uuid, timestamp, transaction)
6666

67-
return foundFog
68-
}, { label: 'checkFogToken' })
69-
70-
if (!fog) {
71-
logger.error(`Fog with UUID ${fogUuid} not found`)
72-
throw new Errors.AgentAuthenticationError(CODES.AGENT_FOG_NOT_FOUND, 'Agent fog not found')
73-
}
67+
fArgs.push(foundFog)
7468

75-
fArgs.push(fog)
76-
77-
return f.apply(this, fArgs)
69+
return runWithTransactionContext(transaction, PRIORITY_INTERACTIVE, () => f.apply(this, fArgs))
70+
}, { label: 'checkFogToken' })
7871
} catch (error) {
7972
const classified = classifyCheckFogTokenFailure(error)
8073
logger.error(`Agent authentication failed: ${classified.message}`, { code: classified.code })

‎src/helpers/agent-auth-error-utils.js‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ const { isSqliteBusyError } = require('./db-busy-retry')
22
const CODES = require('./agent-auth-error-codes')
33
const {
44
AgentAuthenticationError,
5-
ServiceUnavailableError
5+
ServiceUnavailableError,
6+
TransactionTimeoutError,
7+
QueueBackpressureError
68
} = require('./errors')
79

810
function isVaultInfrastructureError (error) {
@@ -19,6 +21,9 @@ function isDatabaseInfrastructureError (error) {
1921
if (!error) {
2022
return false
2123
}
24+
if (error instanceof TransactionTimeoutError || error instanceof QueueBackpressureError) {
25+
return true
26+
}
2227
if (isSqliteBusyError(error)) {
2328
return true
2429
}

‎src/helpers/db-metrics.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ let busyRetries = null
1010
let connectionInvalidated = null
1111
let sqliteFogCountWarning = null
1212
let writeQueueDepthInteractive = null
13+
let transactionTimeouts = null
14+
let backgroundQueueShed = null
1315

1416
function getMeter () {
1517
if (!meter) {
@@ -74,6 +76,13 @@ function initDbMetrics (_sequelize, _provider, queueReader) {
7476
result.observe(depth.background, { priority: 'background' })
7577
})
7678
}
79+
80+
transactionTimeouts = m.createCounter('db.transaction.timeouts', {
81+
description: 'SQLite transaction timeouts by label and priority lane'
82+
})
83+
backgroundQueueShed = m.createCounter('db.write_queue.background_shed', {
84+
description: 'Background SQLite write queue tasks shed during recovery surgery'
85+
})
7786
}
7887

7988
function recordTransactionDuration (attributes, durationMs) {
@@ -100,6 +109,19 @@ function recordSqliteFogCountWarning () {
100109
sqliteFogCountWarning?.add(1)
101110
}
102111

112+
function recordTransactionTimeout (label, priority) {
113+
transactionTimeouts?.add(1, {
114+
label: label || 'unknown',
115+
priority: priority || 'unknown'
116+
})
117+
}
118+
119+
function recordBackgroundQueueShed (count, reason) {
120+
if (count > 0) {
121+
backgroundQueueShed?.add(count, { reason: reason || 'unknown' })
122+
}
123+
}
124+
103125
function maybeRecordConnectionInvalidated (error, provider) {
104126
if (isConnectionInvalidatedError(error)) {
105127
recordConnectionInvalidated(provider)
@@ -114,5 +136,7 @@ module.exports = {
114136
recordConnectionInvalidated,
115137
recordSqliteFogCountWarning,
116138
recordTransactionDuration,
139+
recordTransactionTimeout,
140+
recordBackgroundQueueShed,
117141
recordWriteQueueWaitMs
118142
}

‎src/helpers/errors.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,26 @@ class ServiceUnavailableError extends Error {
159159
}
160160
}
161161

162+
class TransactionTimeoutError extends Error {
163+
constructor (label, priority, timeoutMs) {
164+
const message = `Database transaction timed out after ${timeoutMs}ms (${label || 'unknown'}, ${priority || 'unknown'})`
165+
super(message)
166+
this.name = 'TransactionTimeoutError'
167+
this.label = label || 'unknown'
168+
this.priority = priority || 'unknown'
169+
this.timeoutMs = timeoutMs
170+
this.retryable = true
171+
}
172+
}
173+
174+
class QueueBackpressureError extends Error {
175+
constructor (message = 'SQLite write queue backpressure active') {
176+
super(message)
177+
this.name = 'QueueBackpressureError'
178+
this.retryable = true
179+
}
180+
}
181+
162182
class ReadinessNotReadyError extends Error {
163183
constructor (code, message, basePayload) {
164184
super(message)
@@ -184,6 +204,8 @@ module.exports = {
184204
AuthenticationError,
185205
AgentAuthenticationError,
186206
ServiceUnavailableError,
207+
TransactionTimeoutError,
208+
QueueBackpressureError,
187209
ReadinessNotReadyError,
188210
TransactionError,
189211
ValidationError,

0 commit comments

Comments
 (0)