Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0193b49
Correct indentation
dan-fernandes Nov 13, 2025
a460794
Move role insertion out of scope of table creation
dan-fernandes Nov 14, 2025
49957eb
Fix check for in-memory db
dan-fernandes Nov 14, 2025
9d36d5a
Remove seemingly useless db commit
dan-fernandes Nov 19, 2025
4dca8a9
Skip invalid metrics for StaticPool
dan-fernandes Nov 19, 2025
be9a434
Merge branch 'main' into fix-database-settings
dan-fernandes Nov 19, 2025
22280af
Cache in-memory databases
dan-fernandes Nov 19, 2025
2baf8a7
Pre-commit fixes
dan-fernandes Nov 19, 2025
ba2569e
Add test for in-memory authn
dan-fernandes Nov 19, 2025
4c71dd6
Pre-commit fixes
dan-fernandes Nov 19, 2025
dd602ad
Remove unused global variables
dan-fernandes Nov 19, 2025
9339ab4
Add option to not used cached databases
dan-fernandes Nov 19, 2025
245b5f7
Revert "Add option to not used cached databases"
danielballan Nov 19, 2025
3174de9
In tests with multiple databases, use file to avoid colliding in-memory.
danielballan Nov 19, 2025
dd71251
Use distinct named memory databases instead of on-disk.
danielballan Nov 20, 2025
52dd6ce
Improve checking of in-memory SQLite database.
danielballan Nov 20, 2025
1060690
Use random named memory name to avoid inter-module collisions
danielballan Nov 20, 2025
0427eec
Recognize mode=memory query parameter
danielballan Nov 20, 2025
e6dc79c
Remove database settings
dan-fernandes Nov 20, 2025
dccd4a1
Fix is_memory_sqlite conditional on non-existent property
dan-fernandes Nov 20, 2025
8091a83
Stop caching in-memory databases
dan-fernandes Nov 20, 2025
6e55003
Auto-format
dan-fernandes Nov 20, 2025
2d2e049
More formatting
dan-fernandes Nov 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions example_configs/toy_authentication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ authentication:
tiled_admins:
- provider: toy
id: admin
database:
uri: "sqlite:///file:authn_mem?mode=memory&cache=shared&uri=true"
init_if_not_exists: true
access_control:
access_policy: "tiled.access_control.access_policies:TagBasedAccessPolicy"
args:
Expand Down
16 changes: 16 additions & 0 deletions tiled/_tests/test_configs/config_in_memory_authn.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# config.yml
trees:
- path: /
tree: tiled.examples.generated_minimal:tree
uvicorn:
host: 0.0.0.0
port: 8000
authentication:
providers:
- provider: test
authenticator: tiled.authenticators:DictionaryAuthenticator
args:
users_to_passwords:
alice: PASSWORD
secret_keys:
- SECRET
22 changes: 22 additions & 0 deletions tiled/_tests/test_connection_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from typing import Union

import pytest
from sqlalchemy.engine import URL, make_url

from tiled.server.connection_pool import is_memory_sqlite


@pytest.mark.parametrize(
("uri", "expected"),
[
("sqlite://", True), # accepts str
(make_url("sqlite://"), True), # accepts URL
("sqlite:///:memory:", True),
("sqlite:///file::memory:?cache=shared", True),
("sqlite:///file:name:?cache=shared&mode=memory", True),
("sqlite:////tmp/example.db", False),
],
)
def test_is_memory_sqlite(uri: Union[str, URL], expected: bool):
actual = is_memory_sqlite(uri)
assert actual is expected
26 changes: 26 additions & 0 deletions tiled/_tests/test_in_memory_authn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from pathlib import Path

import yaml

from tiled._tests.utils import enter_username_password
from tiled.client import Context, from_context
from tiled.server.app import build_app_from_config

here = Path(__file__).parent.absolute()


def test_good_path():
"""Test authn database defaults to in-memory catalog"""
with open(here / "test_configs" / "config_in_memory_authn.yml") as config_file:
config = yaml.load(config_file, Loader=yaml.BaseLoader)

app = build_app_from_config(config)
context = Context.from_app(app)

with enter_username_password("alice", "PASSWORD"):
client = from_context(context, remember_me=False)

client.logout()
context.close()

assert True
1 change: 0 additions & 1 deletion tiled/_tests/test_validation.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""
This tests tiled's validation registry
"""

import numpy as np
import pandas as pd
import pytest
Expand Down
9 changes: 4 additions & 5 deletions tiled/authn_database/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,13 @@ async def create_default_roles(db):


async def initialize_database(engine: AsyncEngine) -> None:
async with engine.connect() as conn:
async with engine.begin() as conn:
# Create all tables.
await conn.run_sync(Base.metadata.create_all)
await conn.commit()

# Initialize Roles table.
async with AsyncSession(engine) as db:
await create_default_roles(db)
# Initialize Roles table.
async with AsyncSession(engine) as db:
await create_default_roles(db)


async def purge_expired(db: AsyncSession, cls) -> int:
Expand Down
11 changes: 6 additions & 5 deletions tiled/catalog/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@
ZARR_MIMETYPE,
)
from ..query_registration import QueryTranslationRegistry
from ..server.connection_pool import close_database_connection_pool, get_database_engine
from ..server.connection_pool import (
close_database_connection_pool,
get_database_engine,
is_memory_sqlite,
)
from ..server.core import NoEntry
from ..server.schemas import Asset, DataSource, Management, Revision
from ..server.settings import DatabaseSettings
Expand Down Expand Up @@ -229,10 +233,7 @@ async def execute(self, statement, explain=None):
return result

async def startup(self):
if (self.engine.dialect.name == "sqlite") and (
self.engine.url.database == ":memory:"
or self.engine.url.query.get("mode") == "memory"
):
if is_memory_sqlite(self.engine.url):
# Special-case for in-memory SQLite: Because it is transient we can
# skip over anything related to migrations.
await initialize_database(self.engine)
Expand Down
15 changes: 8 additions & 7 deletions tiled/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,13 +468,14 @@ def override_get_settings():
settings.database_settings.max_overflow = database.max_overflow
if database.init_if_not_exists is not None:
settings.database_init_if_not_exists = database.init_if_not_exists
if authenticators:
# If we support authentication providers, we need a database, so if one is
# not set, use a SQLite database in memory. Horizontally scaled deployments
# must specify a persistent database.
settings.database_settings.uri = (
settings.database_settings.uri or "sqlite://"
)
if authenticators:
# If we support authentication providers, we need a database, so if one is
# not set, use a SQLite database in memory. Horizontally scaled deployments
# must specify a persistent database.
settings.database_settings.uri = (
settings.database_settings.uri
or "sqlite:///file:authdb?mode=memory&cache=shared&uri=true"
)
if (
authenticators
and len(authenticators) == 1
Expand Down
39 changes: 33 additions & 6 deletions tiled/server/connection_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from fastapi import Depends
from sqlalchemy import event
from sqlalchemy.engine import make_url
from sqlalchemy.engine import URL, make_url
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.pool import AsyncAdaptedQueuePool

Expand Down Expand Up @@ -55,18 +55,14 @@ async def __aexit__(self, *excinfo):


def open_database_connection_pool(database_settings: DatabaseSettings) -> AsyncEngine:
if make_url(database_settings.uri).database == ":memory:":
# For SQLite databases that exist only in process memory,
# pooling is not applicable. Just return an engine and don't cache it.
if is_memory_sqlite(database_settings.uri):
engine = create_async_engine(
ensure_specified_sql_driver(database_settings.uri),
echo=DEFAULT_ECHO,
json_serializer=json_serializer,
)

else:
# For file-backed SQLite databases, and for PostgreSQL databases,
# connection pooling offers a significant performance boost.
engine = create_async_engine(
ensure_specified_sql_driver(database_settings.uri),
echo=DEFAULT_ECHO,
Expand Down Expand Up @@ -137,3 +133,34 @@ def _set_sqlite_pragma(conn, record):
cursor = conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()


def is_memory_sqlite(url: Union[URL, str]) -> bool:
"""
Check if a SQLAlchemy URL is a memory-backed SQLite database.
Handles various memory database URL formats:
- sqlite:///:memory:
- sqlite:///file::memory:?cache=shared
- sqlite://
- etc.
"""
url = make_url(url)
# Check if it's SQLite at all
if url.get_dialect().name != "sqlite":
return False

# Check if database is None or empty (default memory DB)
if not url.database:
return True

# Check for explicit :memory: string (case-insensitive)
database = str(url.database).lower()
if ":memory:" in database:
return True

# Check for mode=memory query parameter
if (mode := url.query.get("mode")) and mode.lower() == "memory":
return True

return False
6 changes: 5 additions & 1 deletion tiled/server/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from prometheus_client import Counter, Gauge, Histogram
from sqlalchemy import event
from sqlalchemy.pool import QueuePool
from sqlalchemy.pool import QueuePool, StaticPool

REQUEST_DURATION = Histogram(
"tiled_request_duration_seconds",
Expand Down Expand Up @@ -220,6 +220,10 @@ def on_checkout(dbapi_connection, connection_record, connection_proxy):
DB_POOL_CHECKEDOUT.labels(name).inc()
DB_POOL_CHECKOUTS_TOTAL.labels(name).inc()

# Skip for single-connection database:
if isinstance(pool, StaticPool):
return

# First overflow: we just used the very first overflow slot
if pool.overflow() == 1:
DB_POOL_FIRST_OVERFLOW_TOTAL.labels(name).inc()
Expand Down