Skip to content

Commit f678997

Browse files
committed
docs: Finalize prereqs 3-8 for SQLite command executor
- Prereq 3: Exception handling decisions (HassetteError logging distinction, executor owns timing, CancelledError produces record, listener.once stays in _dispatch) - Prereq 4: Frontend query requirements rewritten from "what should the UI show", Strategy A only (DB single source of truth), session queries, handler invocation drill-down (new UI view), runtime vs DB data boundary documented - Prereq 5: Full schema rewrite with 5 normalized tables (sessions, listeners, scheduled_jobs, handler_invocations, job_executions), session FK on execution tables, updated indexes - Prereq 6: All open questions resolved (aiosqlite, hassette.db, configurable retention, diskcache kept separate) - Prereq 7: Alembic setup with raw SQL migrations matching prereq 5 schema, DatabaseService init flow with orphaned session recovery - Prereq 8: New prereq for DataSyncService decomposition
1 parent 2586259 commit f678997

File tree

6 files changed

+715
-314
lines changed

6 files changed

+715
-314
lines changed

design/research/2026-02-16-sqlite-command-pattern/prereq-03-exception-handling-audit.md

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Prereq 3: Exception Handling Audit
22

3-
**Status**: Not started
3+
**Status**: Decisions made, ready for implementation
44

55
**Parent**: [SQLite + Command Executor research](./research.md)
66

@@ -70,33 +70,35 @@ Success → status="success"
7070
| **Timing source** | Inline `time.monotonic()` | Delegated to `track_execution()` | Executor owns timing directly |
7171
| **Record creation** | N/A (only aggregates) | In `finally` block | Executor creates record in `finally` |
7272

73-
## Questions to answer
73+
## Decisions
7474

75-
### 1. Should the executor preserve the HassetteError vs Exception logging distinction?
75+
### 1. Preserve the HassetteError vs Exception logging distinction
7676

77-
**Current behavior**: `HassetteError``logger.error("...")` (one-line message). General `Exception``logger.exception("...")` (message + traceback).
77+
**Decision**: Yes.
7878

79-
**Recommendation**: Yes, preserve this. It's a good UX — framework errors (rate limit exceeded, invalid config, etc.) produce clean log lines. Unexpected errors get full tracebacks for debugging. The executor can check `isinstance(exc, HassetteError)` to choose the log level.
79+
`HassetteError``logger.error("...")` (one-line message, no traceback). General `Exception``logger.exception("...")` (message + traceback). The executor checks `isinstance(exc, HassetteError)` to choose the log method.
8080

81-
### 2. Should the executor use `track_execution()` or own timing directly?
81+
Framework errors (rate limit exceeded, invalid config, etc.) produce clean log lines. Unexpected errors get full tracebacks for debugging. Good UX worth preserving.
8282

83-
**Current state**: `run_job()` delegates to `track_execution()`, `_dispatch()` does inline timing.
83+
### 2. Executor owns timing directly, not via `track_execution()`
8484

85-
**Recommendation**: Executor owns timing directly. `track_execution()` re-raises all exceptions, but the executor needs to swallow non-Cancelled exceptions. Fighting the context manager's semantics isn't worth it. The executor's `_execute_handler()` and `_execute_job()` methods each have a `started = time.monotonic()` and compute duration in the `finally` block.
85+
**Decision**: Executor owns timing directly.
86+
87+
`track_execution()` re-raises all exceptions, but the executor needs to swallow non-Cancelled exceptions. Fighting the context manager's semantics isn't worth it. The executor captures `time.monotonic()` at the start of invocation and computes `duration_ms` in the `finally` block. The wall-clock `Instant` is captured separately by the executor (see [prereq 1](./prereq-01-data-model.md) — "`ExecutionResult` stays monotonic-only" aside).
8688

8789
`track_execution()` remains available for user code and other contexts where re-raise-after-capture is the right behavior.
8890

89-
### 3. Should CancelledError produce a record?
91+
### 3. CancelledError produces a record, then re-raises
9092

91-
**Current behavior**: `_dispatch()` records cancelled in metrics. `run_job()` records cancelled via `track_execution()`.
93+
**Decision**: Yes, produce a record with `status="cancelled"`, then re-raise.
9294

93-
**Recommendation**: Yes, produce a record with `status="cancelled"`. Then re-raise. The record is queued via `put_nowait()` before the `raise`, so the write queue still gets it.
95+
The record is queued via `put_nowait()` before the re-raise, so the write queue still gets it. Swallowing `CancelledError` would break asyncio's cancellation machinery. See [prereq 1](./prereq-01-data-model.md) for full `cancelled` status documentation.
9496

95-
### 4. Should the executor handle `listener.once` removal?
97+
### 4. Executor does NOT handle `listener.once` removal
9698

97-
**Current behavior**: `_dispatch()` removes one-shot listeners in its `finally` block.
99+
**Decision**: Keep `listener.once` removal in `_dispatch()`, not the executor.
98100

99-
**Recommendation**: No. `listener.once` is bus routing logic, not an execution concern. Keep it in `_dispatch()`:
101+
`listener.once` is bus routing logic, not an execution concern:
100102

101103
```python
102104
async def _dispatch(self, topic, event, listener):
@@ -105,15 +107,21 @@ async def _dispatch(self, topic, event, listener):
105107
self.remove_listener(listener)
106108
```
107109

108-
This keeps the executor focused on execution + recording, and the bus focused on listener lifecycle.
110+
The executor is focused on execution + recording. The bus owns listener lifecycle. Similarly, the executor does not own job rescheduling — that stays in `SchedulerService`.
109111

110-
## Deliverable
112+
## Executor exception contract (summary)
111113

112-
A decision doc (this file, updated with final decisions) that defines the executor's exception contract:
114+
```
115+
CancelledError → record status="cancelled" → RE-RAISE
116+
DependencyError → record status="error", error_type="DependencyError" → logger.error() → SWALLOW
117+
HassetteError → record status="error" → logger.error() (clean, no traceback) → SWALLOW
118+
Exception → record status="error" → logger.exception() (with traceback) → SWALLOW
119+
```
113120

114-
- What it catches, what it swallows, what it re-raises
115-
- How it logs different exception types
116-
- What status values it produces
117-
- What it does NOT own (listener lifecycle, job rescheduling)
121+
**What the executor owns**: invocation, timing, record creation, error classification, logging, error hooks (future).
122+
123+
**What the executor does NOT own**: listener lifecycle (`once` removal), job rescheduling, topic routing, DI resolution.
124+
125+
## Deliverable
118126

119-
This directly feeds into the `CommandExecutor` implementation and the test cases needed to verify behavioral equivalence.
127+
This file (decisions finalized). Feeds directly into the `CommandExecutor` implementation and the test cases needed to verify behavioral equivalence with the current `_dispatch()` and `run_job()` methods.

0 commit comments

Comments
 (0)