Skip to content

Commit 3c16ef9

Browse files
authored
Merge pull request #74 from httpsphl/perf/store
perf(store): stop waiting for the disk and scanning whole tables
2 parents 9d5d4d6 + 60be5e3 commit 3c16ef9

16 files changed

Lines changed: 272 additions & 107 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Indexes for lookups that scanned whole tables. Rows are never deleted, so
2+
-- each of those scans grew with every message, delivery and tool call.
3+
4+
-- Foreign keys: deleting a parent row looks for its children (spec 7.6).
5+
CREATE INDEX messages_task ON messages (task_id);
6+
CREATE INDEX messages_routine ON messages (routine_id);
7+
CREATE INDEX deliveries_message ON deliveries (message_id);
8+
CREATE INDEX tasks_requester_status ON tasks (requester_bot_id, status);
9+
CREATE INDEX tasks_crew ON tasks (crew_id);
10+
CREATE INDEX tasks_origin ON tasks (origin_task_id);
11+
CREATE INDEX approvals_bot ON approvals (bot_id);
12+
CREATE INDEX approvals_chat_item ON approvals (chat_item_id);
13+
14+
-- A tool result finds its call, and the files panel the files a bot wrote.
15+
-- The expressions must match the queries in chat.rs word for word.
16+
CREATE INDEX chat_items_tool_use ON chat_items (bot_id, json_extract(data, '$.toolUseId'))
17+
WHERE kind = 'tool';
18+
CREATE INDEX chat_items_tool_file ON chat_items (bot_id, json_extract(data, '$.file'))
19+
WHERE kind = 'tool' AND json_extract(data, '$.file') IS NOT NULL;
20+
21+
-- A routine's runs are read newest first by rowid; `id` in the key forced a
22+
-- sort. Open runs get their own index.
23+
DROP INDEX routine_runs_routine;
24+
CREATE INDEX routine_runs_routine ON routine_runs (routine_id);
25+
CREATE INDEX routine_runs_queued ON routine_runs (routine_id) WHERE status = 'queued';
26+
27+
-- Only enabled, active routines ever come due.
28+
DROP INDEX routines_due;
29+
CREATE INDEX routines_due ON routines (next_run_at) WHERE enabled AND archived_at IS NULL;

‎crates/botloft-store/src/approvals.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,15 @@ impl Store {
9999
pub fn pending_approvals(&self, bot: &BotId) -> Result<Vec<ApprovalId>> {
100100
let mut stmt = self
101101
.conn
102-
.prepare("SELECT id FROM approvals WHERE bot_id = ?1 AND status = 'pending'")?;
102+
.prepare_cached("SELECT id FROM approvals WHERE bot_id = ?1 AND status = 'pending'")?;
103103
let rows = stmt.query_map([bot.as_str()], |row| parse_column(row, 0))?;
104104
Ok(rows.collect::<rusqlite::Result<_>>()?)
105105
}
106106

107107
/// Expires every pending approval, of one bot or of all: their process
108108
/// is gone, so nobody waits for the answer.
109109
pub fn expire_approvals(&self, bot: Option<&BotId>, now: i64) -> Result<Vec<ApprovalRecord>> {
110-
let mut stmt = self.conn.prepare(&format!(
110+
let mut stmt = self.conn.prepare_cached(&format!(
111111
"UPDATE approvals SET status = 'expired', answered_at = ?2 \
112112
WHERE status = 'pending' AND (?1 IS NULL OR bot_id = ?1) \
113113
RETURNING {COLUMNS}, tool_use_id"

‎crates/botloft-store/src/bots.rs‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use botloft_core::ids::{BotId, CrewId};
44
use botloft_core::protocol::{BotEffort, BotModel, ModelEffort, PermissionMode};
55
use rusqlite::{OptionalExtension, Row, params};
66

7-
use crate::{Result, Store, parse_column, unique_as_duplicate};
7+
use crate::{Result, Store, cached_execute, cached_row, parse_column, unique_as_duplicate};
88

99
/// A bot as stored. The protocol `Bot` adds runtime state and the workspace
1010
/// path, which the daemon derives.
@@ -103,7 +103,7 @@ impl Store {
103103

104104
/// Bots in creation order, optionally limited to one crew.
105105
pub fn bots(&self, crew: Option<&CrewId>, include_archived: bool) -> Result<Vec<BotRecord>> {
106-
let mut stmt = self.conn.prepare(&format!(
106+
let mut stmt = self.conn.prepare_cached(&format!(
107107
"SELECT {COLUMNS} FROM bots \
108108
WHERE (?1 IS NULL OR crew_id = ?1) AND (?2 OR archived_at IS NULL) \
109109
ORDER BY created_at, id"
@@ -192,20 +192,20 @@ impl Store {
192192

193193
/// The Claude Code conversation the bot resumes (spec 7.3).
194194
pub fn session_id(&self, id: &BotId) -> Result<Option<String>> {
195-
Ok(self
196-
.conn
197-
.query_row(
198-
"SELECT session_id FROM bots WHERE id = ?1",
199-
[id.as_str()],
200-
|row| row.get(0),
201-
)
202-
.optional()?
203-
.flatten())
195+
Ok(cached_row(
196+
&self.conn,
197+
"SELECT session_id FROM bots WHERE id = ?1",
198+
[id.as_str()],
199+
|row| row.get(0),
200+
)
201+
.optional()?
202+
.flatten())
204203
}
205204

206205
/// `None` makes the next start a new conversation.
207206
pub fn set_session_id(&self, id: &BotId, session: Option<&str>) -> Result<()> {
208-
self.conn.execute(
207+
cached_execute(
208+
&self.conn,
209209
"UPDATE bots SET session_id = ?2 WHERE id = ?1",
210210
params![id.as_str(), session],
211211
)?;

‎crates/botloft-store/src/chat.rs‎

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use botloft_core::protocol::{Activity, ChatBody, ChatItem};
77
use rusqlite::types::Type;
88
use rusqlite::{Connection, OptionalExtension, Row, params};
99

10-
use crate::{Result, Store, parse_column};
10+
use crate::{Result, Store, cached_execute, cached_row, parse_column};
1111

1212
const COLUMNS: &str = "id, bot_id, data, created_at, updated_at";
1313

@@ -47,7 +47,8 @@ impl Store {
4747
}
4848

4949
pub(crate) fn insert_chat_item_in(conn: &Connection, item: &ChatItem) -> Result<()> {
50-
conn.execute(
50+
cached_execute(
51+
conn,
5152
"INSERT INTO chat_items (id, bot_id, kind, data, created_at, updated_at) \
5253
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
5354
params![
@@ -69,17 +70,16 @@ impl Store {
6970
body: &ChatBody,
7071
now: i64,
7172
) -> Result<Option<ChatItem>> {
72-
Ok(self
73-
.conn
74-
.query_row(
75-
&format!(
76-
"UPDATE chat_items SET kind = ?2, data = ?3, updated_at = ?4 \
73+
Ok(cached_row(
74+
&self.conn,
75+
&format!(
76+
"UPDATE chat_items SET kind = ?2, data = ?3, updated_at = ?4 \
7777
WHERE id = ?1 RETURNING {COLUMNS}"
78-
),
79-
params![id.as_str(), kind(body), json(body)?, now],
80-
from_row,
81-
)
82-
.optional()?)
78+
),
79+
params![id.as_str(), kind(body), json(body)?, now],
80+
from_row,
81+
)
82+
.optional()?)
8383
}
8484

8585
pub fn chat_item(&self, id: &ChatItemId) -> Result<Option<ChatItem>> {
@@ -100,7 +100,7 @@ impl Store {
100100
before: Option<&ChatItemId>,
101101
limit: u32,
102102
) -> Result<Vec<ChatItem>> {
103-
let mut stmt = self.conn.prepare(&format!(
103+
let mut stmt = self.conn.prepare_cached(&format!(
104104
"SELECT {COLUMNS} FROM chat_items \
105105
WHERE bot_id = ?1 \
106106
AND (?2 IS NULL OR rowid < (SELECT rowid FROM chat_items WHERE id = ?2)) \
@@ -116,25 +116,24 @@ impl Store {
116116
/// The bot's newest tool item for `tool_use_id`, to update it when the
117117
/// tool returns.
118118
pub fn tool_item(&self, bot: &BotId, tool_use_id: &str) -> Result<Option<ChatItem>> {
119-
Ok(self
120-
.conn
121-
.query_row(
122-
&format!(
123-
"SELECT {COLUMNS} FROM chat_items \
119+
Ok(cached_row(
120+
&self.conn,
121+
&format!(
122+
"SELECT {COLUMNS} FROM chat_items \
124123
WHERE bot_id = ?1 AND kind = 'tool' \
125124
AND json_extract(data, '$.toolUseId') = ?2 \
126125
ORDER BY rowid DESC LIMIT 1"
127-
),
128-
params![bot.as_str(), tool_use_id],
129-
from_row,
130-
)
131-
.optional()?)
126+
),
127+
params![bot.as_str(), tool_use_id],
128+
from_row,
129+
)
130+
.optional()?)
132131
}
133132

134133
/// Paths of the files the bot's `Write`/`Edit` calls changed, newest
135134
/// first and without repeats; failed calls do not count.
136135
pub fn written_files(&self, bot: &BotId, limit: u32) -> Result<Vec<String>> {
137-
let mut stmt = self.conn.prepare(
136+
let mut stmt = self.conn.prepare_cached(
138137
"SELECT json_extract(data, '$.file') FROM chat_items \
139138
WHERE bot_id = ?1 AND kind = 'tool' \
140139
AND json_extract(data, '$.file') IS NOT NULL \
@@ -148,7 +147,7 @@ impl Store {
148147

149148
/// The conversation-list line: the newest item that has one.
150149
pub fn last_activity(&self, bot: &BotId) -> Result<Option<Activity>> {
151-
let mut stmt = self.conn.prepare(&format!(
150+
let mut stmt = self.conn.prepare_cached(&format!(
152151
"SELECT {COLUMNS} FROM chat_items WHERE bot_id = ?1 AND kind != 'turn' \
153152
ORDER BY rowid DESC LIMIT 1"
154153
))?;

‎crates/botloft-store/src/crews.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ impl Store {
6464

6565
/// Crews in creation order.
6666
pub fn crews(&self, include_archived: bool) -> Result<Vec<Crew>> {
67-
let mut stmt = self.conn.prepare(&format!(
67+
let mut stmt = self.conn.prepare_cached(&format!(
6868
"SELECT {COLUMNS} FROM crews WHERE ?1 OR archived_at IS NULL ORDER BY created_at, id"
6969
))?;
7070
let rows = stmt.query_map([include_archived], from_row)?;

‎crates/botloft-store/src/delete.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ impl Store {
5858
pub fn delete_crew(&self, id: &CrewId) -> Result<bool> {
5959
let tx = self.conn.unchecked_transaction()?;
6060
let bots: Vec<String> = {
61-
let mut stmt = tx.prepare("SELECT id FROM bots WHERE crew_id = ?1")?;
61+
let mut stmt = tx.prepare_cached("SELECT id FROM bots WHERE crew_id = ?1")?;
6262
let rows = stmt.query_map([id.as_str()], |row| row.get(0))?;
6363
rows.collect::<rusqlite::Result<_>>()?
6464
};

‎crates/botloft-store/src/deliveries.rs‎

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use botloft_core::ids::{BotId, DeliveryId};
66
use botloft_core::protocol::{Delivery, DeliveryBacklog, DeliveryState};
77
use rusqlite::{Connection, OptionalExtension, Row, params};
88

9-
use crate::{Result, Store, parse_column, to_sql_int};
9+
use crate::{Result, Store, cached_execute, cached_row, parse_column, to_sql_int};
1010

1111
const COLUMNS: &str =
1212
"id, message_id, bot_id, state, attempts, next_attempt_at, last_error, read_at, updated_at";
@@ -46,7 +46,8 @@ pub enum DeliveryOutcome<'a> {
4646

4747
impl Store {
4848
pub(crate) fn insert_delivery_in(conn: &Connection, delivery: &Delivery) -> Result<()> {
49-
conn.execute(
49+
cached_execute(
50+
conn,
5051
&format!(
5152
"INSERT INTO deliveries ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
5253
),
@@ -82,7 +83,7 @@ impl Store {
8283
state: Option<DeliveryState>,
8384
bot: Option<&BotId>,
8485
) -> Result<Vec<Delivery>> {
85-
let mut stmt = self.conn.prepare(&format!(
86+
let mut stmt = self.conn.prepare_cached(&format!(
8687
"SELECT {COLUMNS} FROM deliveries \
8788
WHERE (?1 IS NULL OR state = ?1) AND (?2 IS NULL OR bot_id = ?2) \
8889
ORDER BY updated_at DESC, rowid DESC LIMIT ?3"
@@ -102,7 +103,7 @@ impl Store {
102103
/// due and nothing else of that bot is being sent. Keeps each bot's
103104
/// messages in order, one at a time.
104105
pub fn due_deliveries(&self, now: i64) -> Result<Vec<Delivery>> {
105-
let mut stmt = self.conn.prepare(&format!(
106+
let mut stmt = self.conn.prepare_cached(&format!(
106107
"SELECT {COLUMNS} FROM deliveries AS d \
107108
WHERE d.state = 'pending' AND d.next_attempt_at <= ?1 \
108109
AND d.rowid = (SELECT MIN(rowid) FROM deliveries \
@@ -123,17 +124,16 @@ impl Store {
123124
now: i64,
124125
lease_until: i64,
125126
) -> Result<Option<Delivery>> {
126-
Ok(self
127-
.conn
128-
.query_row(
129-
&format!(
130-
"UPDATE deliveries SET state = 'sending', lease_until = ?2, updated_at = ?3 \
127+
Ok(cached_row(
128+
&self.conn,
129+
&format!(
130+
"UPDATE deliveries SET state = 'sending', lease_until = ?2, updated_at = ?3 \
131131
WHERE id = ?1 AND state = 'pending' RETURNING {COLUMNS}"
132-
),
133-
params![id.as_str(), lease_until, now],
134-
from_row,
135-
)
136-
.optional()?)
132+
),
133+
params![id.as_str(), lease_until, now],
134+
from_row,
135+
)
136+
.optional()?)
137137
}
138138

139139
/// Records how a pending or sending delivery ended. `None` if it was in
@@ -145,16 +145,16 @@ impl Store {
145145
now: i64,
146146
) -> Result<Option<Delivery>> {
147147
let update = |set: &str, values: &[&dyn rusqlite::ToSql]| {
148-
self.conn
149-
.query_row(
150-
&format!(
151-
"UPDATE deliveries SET {set}, lease_until = NULL \
148+
cached_row(
149+
&self.conn,
150+
&format!(
151+
"UPDATE deliveries SET {set}, lease_until = NULL \
152152
WHERE id = ?1 AND state IN ('pending', 'sending') RETURNING {COLUMNS}"
153-
),
154-
values,
155-
from_row,
156-
)
157-
.optional()
153+
),
154+
values,
155+
from_row,
156+
)
157+
.optional()
158158
};
159159
let id = id.as_str();
160160
let row = match outcome {
@@ -197,24 +197,23 @@ impl Store {
197197
/// The bot began the turn for the delivery sent with `turn_uuid`
198198
/// (spec 9.1). `None` if no unread delivery went with that uuid.
199199
pub fn mark_read(&self, turn_uuid: &str, now: i64) -> Result<Option<Delivery>> {
200-
Ok(self
201-
.conn
202-
.query_row(
203-
&format!(
204-
"UPDATE deliveries SET read_at = ?2, updated_at = ?2 \
200+
Ok(cached_row(
201+
&self.conn,
202+
&format!(
203+
"UPDATE deliveries SET read_at = ?2, updated_at = ?2 \
205204
WHERE turn_uuid = ?1 AND state = 'sent' AND read_at IS NULL \
206205
RETURNING {COLUMNS}"
207-
),
208-
params![turn_uuid, now],
209-
from_row,
210-
)
211-
.optional()?)
206+
),
207+
params![turn_uuid, now],
208+
from_row,
209+
)
210+
.optional()?)
212211
}
213212

214213
/// Deliveries written to the process of `generation` that it never
215214
/// began, oldest first.
216215
pub fn unread_deliveries(&self, bot: &BotId, generation: u64) -> Result<Vec<Delivery>> {
217-
let mut stmt = self.conn.prepare(&format!(
216+
let mut stmt = self.conn.prepare_cached(&format!(
218217
"SELECT {COLUMNS} FROM deliveries \
219218
WHERE bot_id = ?1 AND sent_generation = ?2 AND state = 'sent' AND read_at IS NULL \
220219
ORDER BY rowid"
@@ -253,7 +252,7 @@ impl Store {
253252

254253
/// Returns deliveries whose sender died mid-send to `pending`.
255254
pub fn recover_leases(&self, now: i64) -> Result<Vec<Delivery>> {
256-
let mut stmt = self.conn.prepare(&format!(
255+
let mut stmt = self.conn.prepare_cached(&format!(
257256
"UPDATE deliveries SET state = 'pending', lease_until = NULL, updated_at = ?1 \
258257
WHERE state = 'sending' AND lease_until <= ?1 RETURNING {COLUMNS}"
259258
))?;
@@ -280,8 +279,9 @@ impl Store {
280279

281280
pub fn delivery_backlog(&self) -> Result<DeliveryBacklog> {
282281
Ok(self.conn.query_row(
283-
"SELECT COALESCE(SUM(state IN ('pending', 'sending')), 0), \
284-
COALESCE(SUM(state = 'dead'), 0) FROM deliveries",
282+
// Counted through the state index, not by reading every row.
283+
"SELECT (SELECT COUNT(*) FROM deliveries WHERE state IN ('pending', 'sending')), \
284+
(SELECT COUNT(*) FROM deliveries WHERE state = 'dead')",
285285
[],
286286
|row| {
287287
Ok(DeliveryBacklog {

0 commit comments

Comments
 (0)