SQLite Transcript Store

Concurrent, indexed gateway session persistence — now the default

SqliteTranscriptStore keeps every gateway session as one row in a WAL-preferring SQLite database (falls back to DELETE on hostile filesystems) — the default backend whenever session.persist is on. See Hardened SQLite Storage for the fallback behaviour.

Diagram

SQLite Transcript Store

sqlite (default)

file

🤖 Agent

🌐 Gateway

session.store?

💾 sessions.db

📁 *.json + FileLock

⚡ indexed lookup

🔒 BEGIN IMMEDIATE

Nothing changes for a single-user Python Agent in a script — session APIs are identical. Only the gateway changes: with session.persist: true, transcripts now live in one sessions.db with concurrent readers and indexed routing instead of per-session JSON files guarded by a FileLock.

What changed by default

With session.persist: true, the gateway now builds SqliteTranscriptStore unless you opt out with session.store: file. And session.persist itself now defaults to true — see Gateway Session Persistence.

SettingBeforeAfter
Gateway backend (with session.persist: true)DefaultSessionStore (JSON-per-session + FileLock)SqliteTranscriptStore (WAL SQLite, one row per session)
Layout on disk~/.praisonai/sessions/*.json~/.praisonai/sessions/sessions.db
Concurrent-writer safetyPer-file FileLockBEGIN IMMEDIATE transaction (RESERVED lock)
list / search / gateway routingos.listdir + JSON-parse-every-fileIndexed SELECT
Legacy JSON on upgradeN/AOne-time auto-import into sessions.db on first open
Injected session_store=…UntouchedUntouched — new default only applies when the gateway builds the store

The change is backward-compatible for API callers (public methods are identical) but visible on disk. External tools pointed at *.json files should either read sessions.db or set session.store: file.

Quick Start

  1. Default (YAML) — SQLite is on
    yaml
    # gateway.yaml
    session:
      persist: true
      store: sqlite            # NEW — sqlite (default) | file
      persist_path: ~/.praisonai/sessions/

    session.persist: true is enough — store: sqlite is the default. Transcripts land in ~/.praisonai/sessions/sessions.db.

  2. Keep the legacy JSON layout
    yaml
    # gateway.yaml
    session:
      persist: true
      store: file              # restore pre-#3409 JSON-per-session behaviour
      persist_path: ~/.praisonai/sessions/

    Use store: file when external tooling grep-scans the individual *.json transcripts.

  3. Python embedder using the store directly
    python
    from praisonaiagents import Agent
    from praisonaiagents.session import SqliteTranscriptStore
    
    store = SqliteTranscriptStore(db_path="~/.praisonai/sessions/sessions.db")
    agent = Agent(name="Assistant", instructions="Be helpful", session_store=store)

    An injected store always wins over the session.store config field.

Agent scripts need no changes — the gateway persists sessions for you:

python
from praisonaiagents import Agent

agent = Agent(name="Assistant", instructions="Be helpful")
# The gateway persists this agent's session via SqliteTranscriptStore
# — no code change, no new imports required in agent scripts.

How It Works

Two gateway processes handling the same session serialise inside SQLite, not on a directory scan — the read-modify-write window the old FileLock could lose is closed by BEGIN IMMEDIATE.

Diagram
sessions.dbGateway BGateway Asessions.dbGateway BGateway ABEGIN IMMEDIATE (RESERVED lock)SELECT row (indexed)BEGIN IMMEDIATE (waits)INSERT OR REPLACE + COMMITSELECT row (sees A’s append)INSERT OR REPLACE + COMMIT

Every read-modify-write (add_message and friends) runs its SELECT + INSERT OR REPLACE inside one BEGIN IMMEDIATE transaction, so a second gateway process cannot slip a read between this one’s read and its write.

BehaviourDetail
One-time JSON migrationOn first open of a non-:memory: DB, any *.json transcripts in session_dir are imported once — only if the DB is empty. Files are read in sorted order so results are deterministic. Unreadable, malformed-JSON, and invalid-UTF-8 files are skipped and their bytes preserved untouched — a single bad file never aborts the migration. A summary logs at INFO.
Cross-process append safetyBEGIN IMMEDIATE grabs the RESERVED write lock and holds it to COMMIT, replacing the old cross-process FileLock. The process-local lock alone is not sufficient.
Search, no FTS5search() uses an indexed LIKE candidate query then reuses the parent’s scoring, bookends and lineage-dedup — results match DefaultSessionStore.search(). One bad transcript on disk (unreadable, malformed JSON, or invalid UTF-8) is skipped — search keeps returning valid matches. See Cross-Session Recall → Resilience to Corrupt Transcripts. FTS5 is not required.
Archived-history recallsearch() scans the shared archived + active projection, so compacted turns stay searchable and hits carry the archived: bool flag — matching the other stores (Issue #5031). See Cross-Session Recall.
:memory: supportdb_path=":memory:" gives an ephemeral in-process store for tests; the legacy-JSON migration is a no-op.

Which backend to pick

Diagram

Single-user script

Multi-channel gateway

External tool reads *.json

Ephemeral test

No sqlite3 available

Scenario?

Default is fine

sqlite – default

file

:memory: via Python API

falls back to file, logs warning


Configuration Options

SqliteTranscriptStore constructor:

ArgTypeDefaultDescription
session_dirOptional[str]inheritedRetained for parent-API compat and to derive a default db_path. No JSON files are written here.
db_pathOptional[str]<session_dir>/sessions.dbPath to the SQLite file. ":memory:" for an ephemeral store; ~ is expanded.
**kwargs——Forwarded to DefaultSessionStore — retention, active_window, max_messages, lock_timeout.

SessionConfig.store (gateway YAML):

FieldTypeDefaultDescription
store`”sqlite” \“file”`"sqlite"Gateway transcript backend. "sqlite" uses SqliteTranscriptStore; "file" restores DefaultSessionStore (JSON-per-session + FileLock).

Selection rules:

  • session.persist: false → no store, regardless of store:.
  • session.persist: true + store: sqlite (default) → SqliteTranscriptStore.
  • session.persist: true + store: file → DefaultSessionStore.
  • An injected session_store=… always wins — the field only applies when the gateway builds the store itself.
  • If sqlite3 is unavailable, the gateway falls back to the JSON store and logs a warning.

PRAGMAs applied on first connect (you don’t need to touch these):

PRAGMAValueWhy
journal_modeWALReaders proceed concurrently with a writer
synchronousNORMALDurability vs. throughput trade — safe with WAL
busy_timeoutlock_timeout × 1000 msUses the parent’s lock_timeout (default 5 s)

Schema (one row per session — inspect with the sqlite3 CLI if needed):

sql
CREATE TABLE sessions (
    session_id         TEXT PRIMARY KEY,
    data               TEXT NOT NULL,       -- JSON payload (whole SessionData)
    agent_name         TEXT,
    gateway_session_id TEXT,
    agent_id           TEXT,
    updated_at         TEXT
);
CREATE INDEX idx_sessions_gateway    ON sessions(gateway_session_id);
CREATE INDEX idx_sessions_agent      ON sessions(agent_id);
CREATE INDEX idx_sessions_agent_name ON sessions(agent_name);
CREATE INDEX idx_sessions_updated    ON sessions(updated_at);

Portable export/import

SqliteTranscriptStore implements the same PortableSessionStoreProtocol as DefaultSessionStore and SqliteSessionStore, but every portable operation reads and writes database rows — never JSON sidecars.

python
from praisonaiagents.session import SqliteTranscriptStore

store = SqliteTranscriptStore(db_path="~/.praisonai/sessions/sessions.db")

payload = store.export_all()                                 # scans DB rows, not *.json
payload = store.export_session("s1", include_lineage=True)   # follows DB lineage
report  = store.import_sessions(payload, overwrite=True)     # writes a DB row
OperationWhat it reads/writes
export_all()Scans every row in sessions.db. Stray *.json files in session_dir are ignored.
export_session(id, include_lineage=True)Follows metadata.lineage_id / root_session_id / thread_id across DB rows so compacted / rotated continuations round-trip as one logical session.
import_sessions(payload, overwrite=…)Writes each session as a DB row — no sidecar. Honours overwrite=False end-to-end: a conflicting row lands in ImportReport.skipped with reason "already exists (use overwrite)".
import_sessions(…) against a read-only DB (PRAGMA query_only=ON or similar)Surfaces as a failed write in ImportReport.skipped — never a false success.

The restore path reuses the inherited _save_imported_session orchestration, so the no-window-truncation restore contract still holds: a destination configured with a small truncating active_window / retention="truncate" will not drop exported history on import.

See Gateway Session Portability for the CLI, Python helpers, lineage flags, and ImportReport schema — the same surface works unchanged against the SQLite transcript backend.


Common Patterns

Default gateway config — SQLite is on with just persist: true:

yaml
session:
  persist: true

Explicit legacy fallback — for tooling that scans JSON transcripts:

yaml
session:
  persist: true
  store: file

Custom db_path on a shared volume — WAL + BEGIN IMMEDIATE give cross-process append-safety on a real filesystem:

python
from praisonaiagents.session import SqliteTranscriptStore

store = SqliteTranscriptStore(db_path="/mnt/shared/praisonai/sessions.db")

Best Practices

Don't panic when *.json files disappear

On the first boot after the upgrade, legacy JSON transcripts are imported into sessions.db once, then the JSON files stop being written to. Nothing was lost. To keep the JSON layout, set session.store: file before the first boot.

One bad legacy file won't abort the migration

A legacy *.json file that is unreadable, is not valid JSON, or is not valid UTF-8 (e.g. a truncated write, or a dump in a different encoding) is skipped during the one-time migration and its bytes are preserved on disk — valid transcripts still import. Files are read in sorted order, so this holds even when the bad file would be processed first. Fixed in PraisonAI PR #5555 (Issue #5553).

Don't run sqlite3 maintenance while the gateway is up

WAL + BEGIN IMMEDIATE are safe for concurrent gateway writers, but ad-hoc VACUUM/REINDEX from a separate sqlite3 process during traffic will fight for the RESERVED lock. Do maintenance in a quiet window.

Prefer the config field to injection

If you set session.store in YAML and pass session_store=…, the injected store wins and the config field is ignored. This is intentional — embedders control lifecycle — but easy to be surprised by. Log the resolved store class on startup.

This is not FTS

SqliteTranscriptStore.search() uses LIKE — good for hundreds of sessions, not tens of thousands. For scalable cross-session recall, use SqliteSessionStore (a different class — see the note above).

Encrypt transcripts at rest

This store writes message content to disk in plaintext. Wrap it in EncryptedSessionStore to encrypt content and metadata before they land on the filesystem — see Encrypted Session Store.

sessions.db.corrupt-* files are not deletable-on-sight

They are your only forensic copy of a malformed database. Rotate or archive them off-host if you need long-term retention (retention cap is 3 per file). See Hardened SQLite Storage → Integrity Guard.

Export/import operates on DB rows, not sidecars

export_all, export_session, and import_sessions on this store operate on rows in sessions.db. Stray *.json files in the same directory are not scanned on export, and imports never silently write a sidecar — a read-only DB is reported as a failed write, not a false success. See Portable export/import.


Related