Skip to content

feat: add SQLite database backend for local/self-hosted deployments#75

Draft
xingyaoww wants to merge 1 commit into
mainfrom
openhands/issue-62-sqlite-support
Draft

feat: add SQLite database backend for local/self-hosted deployments#75
xingyaoww wants to merge 1 commit into
mainfrom
openhands/issue-62-sqlite-support

Conversation

@xingyaoww
Copy link
Copy Markdown
Member

Summary

Adds SQLite support via aiosqlite as an alternative to PostgreSQL, addressing the database aspect of #62. This enables open-source users to run the automation service locally without requiring a PostgreSQL instance.

Closes #62 (database support aspect)

Changes

Configuration (automation/config.py)

  • Added db_url setting (AUTOMATION_DB_URL) — a full database URL that takes precedence over individual db_host/db_port/etc. settings
  • Added is_sqlite property for clean dialect detection throughout the codebase
  • Supports both sqlite+aiosqlite:/// and postgresql+asyncpg:// URLs

Database Engine (automation/db.py)

  • Added _create_sqlite_engine() — creates an async SQLite engine via aiosqlite
  • Enables WAL journal mode for better concurrent read performance
  • Enables foreign key enforcement (off by default in SQLite)
  • Refactored existing PostgreSQL path into _create_pg_engine() for clarity
  • Engine selection: SQLite URL → _create_sqlite_engine, GCP instance → _create_gcp_engine, otherwise → _create_pg_engine

Models (automation/models.py)

  • Replaced JSONB (PostgreSQL-specific) with generic JSON type from SQLAlchemy
  • This is transparent to PostgreSQL (SQLAlchemy maps JSON to the native json/jsonb type automatically) while enabling SQLite compatibility
  • Updated docstring noting that postgresql_where in partial indexes is silently ignored on non-PostgreSQL dialects

Scheduler & Dispatcher (automation/scheduler.py, automation/dispatcher.py)

  • Added _is_sqlite() helper to detect the bound engine dialect
  • Conditionally skip FOR UPDATE SKIP LOCKED on SQLite (SQLite uses database-level locking, not row-level locks)
  • This is safe for single-process deployments (the target use case for SQLite)

App Startup (automation/app.py)

  • For SQLite: auto-create all tables via Base.metadata.create_all() during startup
  • This bypasses Alembic migrations which use PostgreSQL-specific DDL (JSONB, UUID, pg_advisory_lock)
  • PostgreSQL deployments continue using Alembic as before

Alembic Migrations (migrations/env.py)

  • Added SQLite detection and support throughout
  • Converts sqlite+aiosqlite URLs to sync sqlite for Alembic (which runs synchronously)
  • Enables render_as_batch=True for SQLite (required for ALTER TABLE operations)
  • Skips PostgreSQL advisory locks when running against SQLite

Dependencies (pyproject.toml)

  • Added aiosqlite>=0.20 to project dependencies

Usage

# Use SQLite instead of PostgreSQL — just set one env var:
export AUTOMATION_DB_URL="sqlite+aiosqlite:///./automations.db"

# Or with absolute path:
export AUTOMATION_DB_URL="sqlite+aiosqlite:////tmp/automations.db"

Testing

Added 14 new tests in tests/test_sqlite_backend.py:

Test Class Tests What's Covered
TestSQLiteConfig 3 is_sqlite property with various URL types
TestSQLiteEngine 3 Engine creation, WAL mode, foreign key enforcement
TestSQLiteModels 5 CRUD for all models with JSON columns on SQLite
TestSQLiteScheduler 2 Scheduler polling without FOR UPDATE SKIP LOCKED
TestSQLiteDispatcher 1 Dispatcher polling without FOR UPDATE SKIP LOCKED

All existing tests continue to pass unchanged.

Design Decisions

  1. Generic JSON over JSONB: SQLAlchemy's JSON type maps to PostgreSQL's native JSON type automatically, so this is a no-op change for PostgreSQL deployments while enabling SQLite.

  2. Auto-create tables for SQLite: Rather than making all existing Alembic migrations dialect-aware (they use JSONB, UUID from sqlalchemy.dialects.postgresql), SQLite deployments create tables directly from model definitions. This is appropriate because SQLite is for fresh local deployments, not migrating existing PostgreSQL data.

  3. Skip FOR UPDATE SKIP LOCKED on SQLite: SQLite only supports database-level locking. For single-process deployments (the target for SQLite), this is safe. The poll_threshold logic in the scheduler still provides fair rotation.

  4. WAL mode: Enabled on every SQLite connection for better concurrent read performance and to reduce lock contention.


This PR was created by an AI agent (OpenHands) on behalf of the user.

@xingyaoww can click here to continue refining the PR

Add SQLite support via aiosqlite as an alternative to PostgreSQL,
enabling open-source users to run the automation service without
requiring a PostgreSQL instance.

Changes:
- config.py: Add db_url setting and is_sqlite property
- db.py: Support SQLite engine creation with WAL mode and FK enforcement
- models.py: Replace PostgreSQL-specific JSONB with cross-database JSON type
- scheduler.py: Skip FOR UPDATE SKIP LOCKED on SQLite (not supported/needed)
- dispatcher.py: Same FOR UPDATE SKIP LOCKED handling for SQLite
- app.py: Auto-create tables for SQLite (skips Alembic PG-specific migrations)
- migrations/env.py: Support SQLite with render_as_batch and skip advisory locks
- pyproject.toml: Add aiosqlite dependency
- tests/test_sqlite_backend.py: Comprehensive tests for SQLite backend

Usage: Set AUTOMATION_DB_URL=sqlite+aiosqlite:///./automations.db

Closes #62 (database support aspect)

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Copy Markdown

Coverage

Coverage Report
FileStmtsMissCoverMissing
__init__.py10100% 
app.py1236447%36, 39, 42, 48, 50, 53, 56–59, 64–65, 67–69, 72–73, 76, 83–84, 87–88, 92, 100–101, 104, 111–112, 114, 117–118, 121, 126–134, 136–138, 224–226, 231–232, 234–235, 237, 240–242, 244–245, 247–248, 253–254, 258, 261, 263
auth.py116595%80, 122, 286, 294–295
config.py1500100% 
constants.py160100% 
db.py602853%49, 59, 64–65, 67, 89–90, 99–100, 102, 110, 117, 129, 132–133, 137–138, 146, 154, 159, 164, 167–173
dispatcher.py1433575%54, 66, 68–69, 146, 150, 172, 180, 199–201, 229–231, 234–236, 289–290, 314–321, 337, 350–351, 364–365, 372–373, 375
event_router.py591967%83, 88, 119–121, 137–138, 156, 158, 160–161, 163, 173, 179–181, 184, 186, 188
exceptions.py40100% 
execution.py24414739%36–38, 73–76, 84–88, 90, 98–100, 105–109, 111, 125–128, 130, 132, 134–137, 139–144, 146, 148–155, 157–158, 160, 196–198, 204–206, 217–220, 226–228, 268–272, 281, 289, 293, 295–296, 301–302, 307, 359, 361, 379, 382–385, 405–408, 410, 418–419, 422, 428, 491–493, 495–501, 504–505, 507, 509–511, 514, 517, 520–523, 525, 528–529, 532–534, 538–539, 543–546, 548, 556–557, 561–563, 565–571, 575, 577, 586–588, 590–592
filter_eval.py50296%161–162
logger.py551769%37, 50–51, 53–59, 74, 77, 101, 103–106
models.py790100% 
preset_router.py1825669%124–126, 234–235, 240–247, 252, 255, 257–258, 269–272, 274–278, 283, 292, 353–355, 466–467, 472–479, 484, 487, 489–490, 501–504, 506–510, 515, 525
router.py1136443%74–75, 95, 97, 100, 102, 116, 129, 131–132, 134–135, 138–140, 151–153, 171–174, 193, 196, 199, 206, 208, 237, 242–244, 247–249, 253–254, 259, 263–266, 268, 276, 278–279, 284–285, 288, 290, 292–294, 297–300, 305, 307–308, 317, 338–340, 344
scheduler.py61985%135–136, 173–174, 189–190, 200–201, 203
schemas.py2651793%31, 165, 171–173, 232–234, 236, 319–320, 323, 328, 333, 477, 485, 492
trigger_matcher.py28389%72–74
uploads.py1075944%138–141, 149–151, 157–158, 161, 170–171, 174–175, 183–184, 186–189, 192–195, 197, 199–201, 203–206, 208–209, 211, 226, 232–233, 236, 239, 242, 245, 247, 260–261, 275, 278–280, 282–283, 285, 291–292, 305, 313–315, 319
watchdog.py1004753%50–51, 63–64, 69–71, 83–84, 228–229, 231, 233, 242, 244–246, 248, 255–257, 259–261, 263–264, 266, 281, 283, 288–291, 293–298, 300–306, 308
webhook_router.py804840%57, 82–83, 107–108, 110, 113–114, 116, 126, 128–132, 137, 139, 151, 154, 157, 164–165, 167, 180, 182–183, 188, 204, 206–207, 213–215, 217–218, 220, 235, 237–238, 243–244, 261, 263–264, 270–271, 273, 275
event_schemas
   __init__.py29196%53
   custom.py33584%52–53, 64–66
   detection.py320100% 
   github.py125496%306, 311, 456, 483
presets
   __init__.py00100% 
storage
   __init__.py50100% 
   factory.py11190%29
   file_store.py22577%21, 30, 35, 40, 64
   google_cloud.py721184%49, 97–102, 136–137, 190, 192
   s3.py1121586%56, 100, 102–103, 107, 109, 190, 213–215, 269–270, 275, 337–338
utils
   __init__.py50100% 
   api_key.py322425%40–41, 46–48, 50, 55, 60, 62–65, 67–68, 70–71, 73, 79, 81–82, 89, 91–92, 98
   cron.py45588%39, 45, 74, 80, 123
   log_context.py100100% 
   run.py771284%74–76, 175–177, 182–184, 231, 237–238
   sandbox.py1017624%40–41, 46–49, 51–53, 55–61, 73, 75, 85–86, 88–90, 92–93, 96–97, 103, 109–111, 121–122, 127–133, 156–157, 159–163, 165–169, 206–207, 209, 211–214, 219–220, 223, 225–226, 232–234, 239–241, 246–247, 255–257, 259
   tarball_validation.py480100% 
   time.py30100% 
   webhook.py511962%46, 51, 119, 129–131, 137, 174, 177–183, 189, 210, 215–216
TOTAL284979871% 

@github-actions
Copy link
Copy Markdown

🚀 Deploy Preview PR Created/Updated

A deploy preview has been created/updated for this PR.

Deploy PR: https://github.com/OpenHands/deploy/pull/3960
Automation SHA: 4397130b4c5e741c1e3738aa47a24cbac775c0e9
Last updated: Apr 28, 2026, 04:13:03 PM ET

Once the deploy PR's CI passes, the automation service will be deployed to the feature environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support open-source self-hosted deployments: pluggable execution backend + local database (converted to project)

2 participants