blob: cec8b3aed89e2992754fe47a5a85c3a5fdc7ea47 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Optional bodies for selected directory rows. This never repairs metadata.
use super::read::sql_position;
use crate::StoreError;
use maka_agent_facts::history::RowPosition;
use sha2::{Digest, Sha256};
use sqlx::sqlite::SqliteWorkerConnection;
use sqlx::{Connection, QueryBuilder, Sqlite, SqliteConnection};
// The ordinary retained database is at most 32 MiB. One active producer may
// exceed that target so tiny fragment/sibling reads do not repeatedly emit it.
// This does not bound a complete operation's projection or live journal peak.
const RETAINED_PAGES: i64 = 8192;
pub(crate) async fn initialize(db: &mut SqliteConnection) -> Result<(), StoreError> {
sqlx::raw_sql(
"PRAGMA temp_store=FILE;
ATTACH DATABASE '' AS transcript_cache;
PRAGMA transcript_cache.page_size=4096;
PRAGMA transcript_cache.auto_vacuum=FULL;
PRAGMA transcript_cache.cache_size=-4096;
PRAGMA transcript_cache.synchronous=OFF;
PRAGMA transcript_cache.journal_mode=DELETE;
CREATE TABLE transcript_cache.rows (
session_id TEXT NOT NULL, source_sequence INTEGER NOT NULL CHECK(source_sequence>=1),
item_index INTEGER NOT NULL CHECK(item_index>=0), turn_id TEXT NOT NULL,
message_id TEXT NOT NULL, payload BLOB NOT NULL, digest TEXT NOT NULL,
total_bytes INTEGER NOT NULL CHECK(total_bytes>0),
PRIMARY KEY(session_id,source_sequence,item_index), UNIQUE(session_id,message_id)
);
CREATE TABLE transcript_cache.text (
session_id TEXT NOT NULL, source_sequence INTEGER NOT NULL CHECK(source_sequence>=1),
item_index INTEGER NOT NULL CHECK(item_index>=0),
field TEXT NOT NULL CHECK(field IN ('body','thinking')),
timestamp INTEGER NOT NULL, role TEXT NOT NULL, body BLOB NOT NULL,
PRIMARY KEY(session_id,source_sequence,item_index,field)
);
CREATE TABLE transcript_cache.active_source (
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
session_id TEXT NOT NULL, source_sequence INTEGER NOT NULL
);",
)
.execute(&mut *db)
.await?;
Ok(())
}
/// Keep every ensured row pinned until the original operation has consumed it,
/// including final history boundaries. Accepted work stays on the writer owner.
pub(super) fn run<'c, T: Send + 'c>(
db: &'c mut SqliteConnection,
read: impl for<'a> FnOnce(
&'a mut SqliteConnection,
) -> crate::connection::BoxFuture<'a, Result<T, StoreError>>
+ Send
+ 'c,
) -> crate::connection::BoxFuture<'c, Result<T, StoreError>> {
Box::pin(async move {
let mut tx = db.begin().await?;
let before: i64 = sqlx::query_scalar("SELECT total_changes()")
.fetch_one(&mut *tx)
.await?;
match read(&mut tx).await {
Ok(value) => {
let after: i64 = sqlx::query_scalar("SELECT total_changes()")
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
// Cache fills and retained-source changes require maintenance.
// An unchanged fragment cannot add reclaimable bodies, even
// when its retained producer alone exceeds the page target.
if after != before {
trim(db).await?;
}
Ok(value)
}
Err(error) => {
// Preserve the canonical read failure. No maintenance follows an
// unsuccessful read or a rollback with an uncertain result.
let _ = tx.rollback().await;
Err(error)
}
}
})
}
async fn trim(db: &mut SqliteConnection) -> Result<(), StoreError> {
let pages: i64 = sqlx::query_scalar("PRAGMA transcript_cache.page_count")
.fetch_one(&mut *db)
.await?;
if pages <= RETAINED_PAGES {
return Ok(());
}
let mut tx = db.begin().await?;
sqlx::raw_sql(
"DELETE FROM transcript_cache.text WHERE NOT EXISTS (
SELECT 1 FROM transcript_cache.active_source a
WHERE a.session_id=text.session_id AND a.source_sequence=text.source_sequence
);
DELETE FROM transcript_cache.rows WHERE NOT EXISTS (
SELECT 1 FROM transcript_cache.active_source a
WHERE a.session_id=rows.session_id AND a.source_sequence=rows.source_sequence
);",
)
.execute(&mut *tx)
.await?;
// FULL auto-vacuum reclaims free database pages at this commit, including
// an opened temporary backing file. DELETE alone would retain high-water.
tx.commit().await?;
Ok(())
}
async fn retain_source(
db: &mut SqliteWorkerConnection<'_>,
session: &str,
source: i64,
) -> Result<(), StoreError> {
sqlx::query("INSERT INTO transcript_cache.active_source VALUES(1,?1,?2)
ON CONFLICT(singleton) DO UPDATE SET session_id=excluded.session_id,source_sequence=excluded.source_sequence
WHERE active_source.session_id!=excluded.session_id OR active_source.source_sequence!=excluded.source_sequence")
.bind(session).bind(source).execute(db).await?;
Ok(())
}
pub(super) async fn ensure(
tx: &mut SqliteConnection,
session: &str,
position: RowPosition,
) -> Result<(), StoreError> {
let session = session.to_owned();
crate::connection::worker_query(tx, move |worker| {
Box::pin(async move { ensure_on_worker(worker, &session, position).await })
})
.await
}
pub(super) async fn ensure_on_worker(
db: &mut SqliteWorkerConnection<'_>,
session: &str,
position: RowPosition,
) -> Result<(), StoreError> {
let (source, item) = sql_position(position)?;
let (turn, message): (String, String) = sqlx::query_as(
"SELECT turn_id,message_id FROM transcript_directory WHERE session_id=?1 AND source_sequence=?2 AND item_index=?3",
).bind(session).bind(source).bind(item).fetch_optional(&mut *db).await?
.ok_or_else(|| StoreError::InvalidTransition("history row was not found".into()))?;
let cached: Option<(String, String)> = sqlx::query_as(
"SELECT turn_id,message_id FROM transcript_cache.rows WHERE session_id=?1 AND source_sequence=?2 AND item_index=?3",
).bind(session).bind(source).bind(item).fetch_optional(&mut *db).await?;
if let Some(identity) = cached {
if identity != (turn, message) {
return Err(StoreError::TranscriptConflict);
}
retain_source(db, session, source).await?;
return Ok(());
}
// S is the cache unit: emission already owns every original row from this
// selected producer. Retain these rows once, never replayed earlier sources.
// Text remains demand-driven per visited row. A hit needs no sibling fill.
let rows = super::emit::source_on_worker(db, session, position.source_sequence).await?;
if !rows.iter().any(|row| row.position == position) {
return Err(StoreError::TranscriptConflict);
}
// Bound statement size and extra encoded bytes, not producer size. One
// oversized row still works; no further siblings join its batch. Every
// batch stays inside the caller's original read transaction.
let mut rows = rows.iter().peekable();
while rows.peek().is_some() {
let mut encoded = Vec::with_capacity(32);
let mut encoded_bytes = 0;
for row in rows.by_ref().take(32) {
if row.position.source_sequence != position.source_sequence {
return Err(StoreError::TranscriptConflict);
}
let (_, item) = sql_position(row.position)?;
let payload = serde_json::to_vec(&row.message)?;
let bytes = i64::try_from(payload.len()).map_err(|_| StoreError::PrefixTooLarge)?;
let digest = format!("sha256:{}", hex::encode(Sha256::digest(&payload)));
encoded_bytes += payload.len();
encoded.push((item, &row.message, payload, digest, bytes));
if encoded_bytes >= 64 * 1024 {
break;
}
}
let mut identities =
QueryBuilder::<Sqlite>::new("WITH wanted(item_index,turn_id,message_id) AS (");
identities.push_values(&encoded, |mut values, (item, message, ..)| {
values
.push_bind(item)
.push_bind(&message.turn_id)
.push_bind(&message.id);
});
identities.push(") SELECT count(*) FROM wanted JOIN transcript_directory d ON d.item_index=wanted.item_index AND d.turn_id=wanted.turn_id AND d.message_id=wanted.message_id WHERE d.session_id=")
.push_bind(session).push(" AND d.source_sequence=").push_bind(source);
let matched: i64 = identities.build_query_scalar().fetch_one(&mut *db).await?;
if matched != encoded.len() as i64 {
return Err(StoreError::TranscriptConflict);
}
let mut insert = QueryBuilder::<Sqlite>::new(
"INSERT OR IGNORE INTO transcript_cache.rows (session_id,source_sequence,item_index,turn_id,message_id,payload,digest,total_bytes) ",
);
insert.push_values(
&encoded,
|mut values, (item, message, payload, digest, bytes)| {
values
.push_bind(session)
.push_bind(source)
.push_bind(item)
.push_bind(&message.turn_id)
.push_bind(&message.id)
.push_bind(payload)
.push_bind(digest)
.push_bind(bytes);
},
);
insert.build().execute(&mut *db).await?;
// A conflict must preserve an existing body and fail the entire read.
// Compare every field even when INSERT OR IGNORE changed no rows.
let mut verify = QueryBuilder::<Sqlite>::new(
"WITH wanted(item_index,turn_id,message_id,payload,digest,total_bytes) AS (",
);
verify.push_values(
&encoded,
|mut values, (item, message, payload, digest, bytes)| {
values
.push_bind(item)
.push_bind(&message.turn_id)
.push_bind(&message.id)
.push_bind(payload)
.push_bind(digest)
.push_bind(bytes);
},
);
verify.push(") SELECT count(*) FROM wanted JOIN transcript_cache.rows c ON c.item_index=wanted.item_index AND c.turn_id=wanted.turn_id AND c.message_id=wanted.message_id AND c.payload=wanted.payload AND c.digest=wanted.digest AND c.total_bytes=wanted.total_bytes WHERE c.session_id=")
.push_bind(session).push(" AND c.source_sequence=").push_bind(source);
let matched: i64 = verify.build_query_scalar().fetch_one(&mut *db).await?;
if matched != encoded.len() as i64 {
return Err(StoreError::TranscriptConflict);
}
}
retain_source(db, session, source).await?;
Ok(())
}
#[cfg(test)]
#[path = "bodycache_tests.rs"]
mod tests;