You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Low-Level Design: AIOD REST API Database Layer Rearchitecture
Document Version: 1.0 Status: Draft for Review Prepared By: Kartik Sapra Date: April 2026
Table of Contents
Introduction
Architectural Context
Class and Object Design
Design Patterns
Module Interactions and Interfaces
Error Handling and Edge Cases
Concurrency and Connection Management
Database Schema
Security Considerations
Performance and Scalability
Maintainability and Documentation
1. Introduction
1.1 Purpose
This document provides a detailed low-level design for the rearchitecture of the database layer within the AIOD REST API platform. The design is informed by a systematic code review of the existing implementation and targets twelve confirmed deficiencies. It serves as the technical specification for all engineers working on the database layer during the rearchitecture phase.
1.2 Problem Statement
The current database layer has grown organically and carries several structural problems that now affect reliability, security, and operational confidence. Migrations cannot be auto-generated because Alembic's metadata reference is unset. Sessions do not roll back on failure. Passwords are embedded in plain-text connection strings. Identifiers are generated without collision protection. Hidden database sessions open inside the serialization layer during HTTP response rendering, causing untracked N+1 query patterns. Hard-deletion runs as a series of independent commits, meaning a mid-run failure leaves the database in a partially cleaned state. These are not theoretical risks; they are observable flaws in the current production path.
1.3 Scope
This document covers the following components:
Component
In Scope
src/database/session.py
Yes
src/database/identifiers.py
Yes
src/database/model/serializers.py
Yes
src/database/model/helper_functions.py
Yes
src/database/model/named_relation.py
Yes
src/database/deletion/hard_delete.py
Yes
src/database/review.py
Yes
src/database/authorization.py
Yes
alembic/alembic/env.py and versions/
Yes
src/routers/
Partial (query call-sites only)
Connector layer
No
Authentication (Keycloak)
No
1.4 Functional Requirements
Sessions must roll back automatically on any unhandled exception.
The migration toolchain must detect schema drift between ORM models and the live database.
Every migration must have a working downgrade() path.
Identifier assignment must not produce unhandled primary key violations.
Soft-deleted resources must be excluded from relationship responses without opening additional database sessions during serialization.
Hard-deletion of expired soft-deleted records must execute as a single atomic transaction.
The Submission.assets property must not issue one query per asset.
1.5 Non-Functional Requirements
The database connection URL must never appear in logs with a plaintext password.
Dialect-specific SQL must be derived from the live engine at runtime, not from an environment variable read at module import time.
The connection pool must be explicitly configurable through application configuration, with a default pool_size of 10, max_overflow of 20, and pool_pre_ping enabled.
The approach for determining whether an AIoDConcept subclass is abstract must be explicit and not break when a leaf class is subclassed for any reason.
2. Architectural Context
2.1 High-Level Component Overview
The AIOD REST API database layer sits between the HTTP router layer and the MySQL database. It is composed of four logical subsystems: session management, ORM models, the migration chain, and operational utilities (deletion, review, authorization).
graph TD
A[FastAPI Routers] --> B[Session Management]
A --> C[Serialization Layer]
B --> D[(MySQL Database)]
C --> B
E[ORM Model Layer] --> D
F[Alembic Migrations] --> D
G[Deletion Utilities] --> B
H[Authorization Module] --> B
I[Review Module] --> B
C --> E
A --> E
Loading
2.2 Current vs. Proposed Architecture
The fundamental model hierarchy and relationship declaration system are sound and will be retained. What changes are the infrastructure wrappers around them: how sessions are created and closed, how identifiers are assigned, how the serializer resolves soft deletes, how the migration toolchain detects schema drift, and how the connection pool is configured.
graph LR
subgraph Current
S1[DbSession - no rollback] --> DB1[(MySQL)]
ID1[random.choices - no retry] --> DB1
SER1[GetterDict opens new DbSession per item] --> DB1
HD1[hard_delete commits per concept] --> DB1
AL1[Alembic - target_metadata = None] -.->|no drift detection| DB1
end
subgraph Proposed
S2[DbSession - explicit rollback] --> DB2[(MySQL)]
S2a[CommitDbSession - auto commit] --> DB2
ID2[assign_identifier - secrets + retry] --> DB2
SER2[with_loader_criteria - query-level filter] --> DB2
HD2[hard_delete - single transaction] --> DB2
AL2[Alembic - target_metadata = SQLModel.metadata] -->|drift detection| DB2
end
Loading
3. Class and Object Design
3.1 Session Management
The two session types establish a clear contract: DbSession is for reads and operations where the caller controls commit timing, and CommitDbSession is for write paths that should commit on success and roll back on any exception.
Key Design Decision:CommitDbSession inherits from DbSession and overrides __exit__ to call session.commit() before session.close(). Both call session.rollback() in the exception path. This makes the difference in behaviour explicit at the call site rather than implicit in the session lifecycle.
IdentifierAssigner.assign_identifier() calls _is_taken() before assigning the candidate to the instance. If the candidate is taken it generates a new one, up to max_retries attempts. After exhausting retries it raises a typed IdentifierExhaustionError rather than propagating a raw database constraint violation.
3.3 Serialization Layer
classDiagram
class Serializer {
<<abstract>>
+serialize(model) Any
+value(model, attribute_name) Any
}
class AttributeSerializer {
-str attribute_name
+serialize(model) Any
}
class GetPathSerializer {
-str path
-Serializer inner_serializer
+serialize(model) Any
+value(model, attribute_name) Any
}
class DeSerializer {
<<abstract>>
+deserialize(session, serialized, user) Any
}
class FindByNameDeserializer {
-type clazz
+deserialize(session, name, user) int
}
class FindByIdentifierDeserializerList {
-type clazz
+deserialize(session, ids, user) list
}
class QueryOptions {
+exclude_deleted() LoaderCriteria
}
Serializer <|-- AttributeSerializer
Serializer <|-- GetPathSerializer
DeSerializer <|-- FindByNameDeserializer
DeSerializer <|-- FindByIdentifierDeserializerList
QueryOptions ..> AIoDConcept : filters on date_deleted
Loading
The QueryOptions class is a new addition. Its exclude_deleted() method returns a SQLAlchemy with_loader_criteria object that the router applies once at query time, removing the need for any Python-side soft-delete filtering inside the serializer and eliminating hidden session opens entirely.
classDiagram
class User {
+str subject_identifier
}
class Group {
+int identifier
+str name
}
class Permission {
+int aiod_entry_identifier
+str user_identifier
+int group_identifier
+PermissionType type_
}
class PermissionType {
<<enum>>
READ
WRITE
ADMIN
+__lt__(other) bool
}
class AIoDEntryORM {
+int identifier
+EntryStatus status
+datetime date_modified
+datetime date_created
+list permissions
}
AIoDEntryORM "1" --> "*" Permission : has
Permission "*" --> "0..1" User : granted to
Permission "*" --> "0..1" Group : granted to
Group "1" --> "*" User : contains
PermissionType --> Permission : type of
Loading
A CHECK constraint enforces that exactly one of user_identifier or group_identifier is non-null on each Permission row. This replaces the existing TODO comment with a proper implementation.
4. Design Patterns
4.1 Singleton (Engine Management)
The EngineSingleton uses the Borg pattern (shared state dictionary) to ensure one SQLAlchemy engine exists per process. This pattern is retained because it is correct and well implemented. The only change is adding explicit pool configuration parameters sourced from config.default.toml rather than relying on SQLAlchemy defaults.
4.2 Context Manager (Session Lifecycle)
Python context managers (__enter__ / __exit__) are used to bind session lifecycle to code blocks. This pattern guarantees that rollback and close are called regardless of how the block exits. The existing implementation already uses this pattern correctly for DbSession; the change is adding rollback on exception and introducing CommitDbSession as a dedicated write-path variant.
4.3 Repository (Identifier Assignment)
IdentifierAssigner acts as a repository-style service that encapsulates the rules for creating a valid, unique identifier. Callers do not need to know anything about collision handling or retry logic; they call assign_identifier() and receive a guarantee that the instance now has a unique primary key or an exception is raised with a clear message.
4.4 Strategy (Serializer / Deserializer)
The existing Serializer and DeSerializer abstract base classes already implement the Strategy pattern. Each relationship field on a model declares its own serializer and deserializer strategy independently. This design is retained as-is.
4.5 Decorator (Loader Criteria)
QueryOptions.exclude_deleted() wraps SQLAlchemy's with_loader_criteria in a domain-specific helper that is applied as a decorator to any query involving AIoDConcept relationships. This keeps the soft-delete filtering policy in one place. Any future change to what "deleted" means (for example, adding an is_archived state) requires a change in one class.
5. Module Interactions and Interfaces
5.1 Session Lifecycle Sequence
This diagram shows a typical write request, illustrating where commit and rollback now occur.
sequenceDiagram
participant Router
participant IdentifierAssigner
participant IdentifierGenerator
participant Session
participant Database
Router->>IdentifierAssigner: assign_identifier(session, Dataset, instance)
loop Up to max_retries
IdentifierAssigner->>IdentifierGenerator: generate()
IdentifierGenerator-->>IdentifierAssigner: "dset_Xk9mP..."
IdentifierAssigner->>Session: session.get(Dataset, candidate)
Session->>Database: SELECT WHERE identifier = ?
Database-->>Session: None (no collision)
IdentifierAssigner->>IdentifierAssigner: instance.identifier = candidate
IdentifierAssigner-->>Router: returns
end
alt All retries exhausted
IdentifierAssigner->>Router: raises IdentifierExhaustionError
end
Loading
5.3 Soft-Delete Filtering Sequence (Proposed)
This replaces the current approach of opening hidden sessions inside GetterDict.get().
sequenceDiagram
participant Router
participant Session
participant QueryOptions
participant Database
participant Serializer
Router->>QueryOptions: exclude_deleted()
QueryOptions-->>Router: LoaderCriteria(date_deleted IS NULL)
Router->>Session: exec(select(Dataset).options(exclude_deleted()))
Session->>Database: SELECT ... WHERE date_deleted IS NULL (JOIN applied to relationships)
Database-->>Session: Dataset rows with relationships pre-filtered
Session-->>Router: list of Dataset instances
Router->>Serializer: serialize(dataset_instance)
Serializer-->>Router: response dict (no hidden DB calls)
Loading
5.4 Hard-Delete Sequence (Proposed)
sequenceDiagram
participant CronJob
participant HardDeleteService
participant CommitDbSession
participant Session
participant Database
CronJob->>HardDeleteService: hard_delete_older_than(timedelta(minutes=60))
HardDeleteService->>CommitDbSession: enter context
CommitDbSession->>Session: create Session
loop For each AIoDConcept subclass
HardDeleteService->>Session: exec(DELETE WHERE date_deleted < threshold)
Session->>Database: DELETE statement (within transaction)
end
CommitDbSession->>Session: session.commit()
Session->>Database: COMMIT (all deletes committed atomically)
alt Any exception
CommitDbSession->>Session: session.rollback()
Session->>Database: ROLLBACK (no partial state)
end
Loading
6. Error Handling and Edge Cases
6.1 Exception Class Hierarchy
A typed exception hierarchy ensures that callers can catch precisely the errors they care about, rather than catching broad Exception or relying on database constraint violations bubbling up as raw SQLAlchemy errors.
classDiagram
class AIoDDatabaseError {
<<base>>
+str message
}
class IdentifierExhaustionError {
+str model_class_name
+int max_retries
}
class DialectResolutionError {
+str expected
+str actual
}
class SessionDetachedError {
+str operation
}
AIoDDatabaseError <|-- IdentifierExhaustionError
AIoDDatabaseError <|-- DialectResolutionError
AIoDDatabaseError <|-- SessionDetachedError
Loading
6.2 Edge Case Handling
Scenario
Current Behaviour
Proposed Behaviour
Primary key collision on insert
Unhandled IntegrityError, 500 response
IdentifierAssigner retries up to 5 times, then raises IdentifierExhaustionError, which maps to HTTP 503
Session not committed, exception raised
Session closes without rollback, state may be ambiguous
DbSession.__exit__ calls rollback() before close() unconditionally on exception
Submission.assets called on detached instance
Returns None silently via Session.object_session()
SessionDetachedError raised with the operation name
Hard-delete crashes mid-loop
Some concept types deleted, others not
Full ROLLBACK via CommitDbSession, no partial state
Wrong dialect env var on startup
Wrong CHECK constraint SQL generated silently
DialectResolutionError raised at engine initialization, server fails fast
7. Concurrency and Connection Management
7.1 Connection Pool Configuration
The current EngineSingleton sets only pool_recycle. The proposed configuration exposes all relevant pool parameters through config.default.toml, with the following recommended defaults:
Parameter
Default
Rationale
pool_size
10
Enough for moderate concurrent API load without exhausting MySQL's connection limit
max_overflow
20
Allows burst capacity up to 30 total connections before PoolTimeout
pool_timeout
30
Callers receive a Python exception after 30 seconds rather than hanging indefinitely
pool_recycle
3600
Existing value; prevents stale connections on long-lived deployments
pool_pre_ping
True
Sends SELECT 1 before reuse to detect connections dropped by the MySQL server
7.2 Session Thread Safety
Each request in FastAPI receives its own session instance via get_session(). Sessions are never shared across threads. The EngineSingleton is thread-safe because create_engine is called only once (guarded by the Borg shared-state pattern) and the resulting engine's connection pool handles concurrent access internally.
7.3 Alembic Migration Concurrency
Migrations are run as a single-threaded process against the live database before the API server starts. The migration chain uses revision and down_revision to enforce a linear order. Running two migration processes simultaneously would produce undefined behaviour; this is prevented at the infrastructure level by the Docker Compose startup sequence.
8. Database Schema
8.1 Core Entity Relationship Diagram
erDiagram
aiod_entry {
int identifier PK
enum status
datetime date_modified
datetime date_created
}
aiod_concept {
varchar identifier PK
str platform
str platform_resource_identifier
datetime date_deleted
int aiod_entry_identifier FK
}
ai_resource {
varchar identifier PK
varchar aiod_concept_id FK
}
ai_asset {
varchar identifier PK
varchar ai_resource_id FK
}
dataset {
varchar identifier PK
varchar ai_asset_id FK
}
user {
varchar subject_identifier PK
}
group {
int identifier PK
varchar name
}
permission {
int identifier PK
int aiod_entry_identifier FK
varchar user_identifier FK
int group_identifier FK
enum type_
}
submission {
int identifier PK
datetime request_date
varchar requestee_identifier FK
}
review {
int identifier PK
enum decision
datetime decision_date
int submission_identifier FK
varchar reviewer_identifier FK
}
platform {
varchar name PK
}
aiod_concept ||--|| aiod_entry : "has"
aiod_concept }o--|| platform : "sourced_from"
ai_resource ||--|| aiod_concept : "is_a"
ai_asset ||--|| ai_resource : "is_a"
dataset ||--|| ai_asset : "is_a"
aiod_entry ||--o{ permission : "governed_by"
permission }o--o| user : "granted_to"
permission }o--o| group : "granted_to"
group }o--o{ user : "contains"
submission }o--|| user : "requested_by"
review }o--|| submission : "responds_to"
review }o--|| user : "made_by"
Loading
8.2 Identifier Design
Every AIoDConcept subclass uses a string primary key of the form {prefix}_{24 random alphanumeric characters}. The prefix is at most four characters and is declared as __abbreviation__ on each model class (for example, dset for Dataset, mlmd for MLModel). This makes identifiers human-readable in logs and API responses without requiring a join to resolve the resource type.
The current identifier columns use integer primary keys in intermediate tables (AIResourceORM, AIAssetTable, etc.) and are synchronized with the AIoDConcept string identifier via SQL AFTER INSERT triggers. This synchronization mechanism is retained as-is.
8.3 Constraint Inventory
Table
Constraint
Type
Purpose
aiod_concept
platform XOR platform_resource_identifier
CHECK
Platform and identifier must both be set or both be null
Allows the same external resource to be re-imported after soft deletion
named_relation
name = LOWER(name)
CHECK
Enum-style names are always lowercase
permission
user_identifier XOR group_identifier NOT NULL
CHECK (proposed)
A permission targets exactly one of a user or a group
8.4 Index Design
Beyond the constraints above, the following indexes support the most common query patterns:
Table
Index Columns
Query Pattern
aiod_concept
platform, platform_resource_identifier
Connector upsert lookups
aiod_concept
date_deleted
Hard-delete batch queries
named_relation
name
Taxonomy term resolution
aiod_entry
status
Draft / published listing filters
9. Security Considerations
9.1 Database Credential Handling
The connection URL is constructed using sqlalchemy.engine.URL.create() instead of Python string interpolation. This object's __repr__ and __str__ methods mask the password, ensuring it does not appear in log output, tracebacks, or debug tooling even when echo=True is enabled during development.
Credentials themselves are sourced from config.default.toml, which is excluded from version control via .gitignore. In production, the configuration values are injected through environment variables or a secrets manager mounted at the container level.
9.2 Permission Enforcement
The PermissionType enum defines a total ordering (READ < WRITE < ADMIN) enforced by __lt__. Authorization checks always specify a minimum required level using at_least=PermissionType.WRITE and so on, rather than equality. This means that an ADMIN user always satisfies a WRITE check without special-casing.
The proposed Group expansion adds no new attack surface because the CHECK constraint at the database level prevents any permission row from simultaneously referencing a user and a group.
9.3 Taxonomy Input Validation
Non-connector users are prevented from submitting arbitrary taxonomy terms by FindByNameDeserializer, which checks the official flag on Taxonomy rows. This check happens in the deserialization layer before any data reaches the ORM, so it applies uniformly across all endpoints that accept taxonomy fields.
Platform resource identifiers are validated against per-platform regex patterns (Zenodo, HuggingFace, OpenML) before storage. This prevents malformed external identifiers from entering the platform and simplifies connector reconciliation.
10. Performance and Scalability
10.1 Eliminating Hidden N+1 Queries
The most significant performance improvement in this rearchitecture is the removal of hidden database sessions from the serialization layer. The current is_soft_deleted() function inside create_getter_dict opens a new session for every related item it checks. On a list endpoint returning 50 datasets, each with 10 relationships, this generates up to 500 hidden database roundtrips per response.
The proposed exclude_deleted() loader criteria moves this filter to the query level. The database engine applies it in a single join during the initial SELECT, and the serialization layer receives already-filtered data without touching the database again.
10.2 Batched Asset Loading in Review
Submission.assets currently issues one session.get() per asset identifier. The proposed implementation groups identifiers by asset type prefix and issues one SELECT ... WHERE identifier IN (...) per type. For a typical submission involving five datasets and two ML models, this reduces seven queries to two.
10.3 Caching of Computed Mappings
get_asset_type_by_abbreviation() is decorated with @functools.cache in the existing code and is correctly retained. The mapping from abbreviation prefix to model class is computed once at startup and reused. The proposed identifier assigner uses this same cache when resolving the target model class for collision detection.
10.4 Scalability Boundary
The database layer itself is stateless at the application level: no in-memory query results are shared across requests. Horizontal scaling of the API tier requires only that all instances share the same MySQL database. The connection pool on each instance (10 base + 20 overflow = 30 max connections) must be factored into MySQL's max_connections setting when planning capacity for multiple replicas.
11. Maintainability and Documentation
11.1 Alembic Autogenerate
Enabling target_metadata = SQLModel.metadata in alembic/env.py allows engineers to run alembic revision --autogenerate -m "description" after any model change. Alembic compares the ORM metadata against the live schema and generates a migration stub that includes the detected differences. Engineers review and adjust the stub before committing. This replaces the current manual process where a developer must remember to write a migration and risk silent schema drift if they forget.
All ORM table-backed modules must be imported before target_metadata is referenced in env.py so that SQLAlchemy can register their table definitions. A module-level import block at the top of env.py will enumerate these explicitly.
11.2 Downgrade Discipline
A CI check will fail any pull request that adds a migration with a pass-only downgrade(). For migrations where a true structural downgrade is impractical (large data migrations, type changes with data loss), the downgrade() body must contain a comment explaining why a backup restore is required, and the migration must be tagged with a # no-downgrade-required marker that the CI check accepts as a valid excuse.
11.3 Abstract Resource Flag
The __abstract_resource__ class variable replaces the structural heuristic in non_abstract_subclasses(). Each model class explicitly declares whether it is concrete or abstract. This is documented in the developer README as a requirement for any new model added to the hierarchy.
# Correct declaration for a concrete modelclassDataset(AIoDAIConcept):
__abstract_resource__=False__abbreviation__="dset"# Correct declaration for an intermediate abstract modelclassAIAsset(AIResource):
__abstract_resource__=True
11.4 Coding Standards for This Layer
Rule
Rationale
All write paths use CommitDbSession
Makes transaction boundary visible at the call site
All read paths use DbSession
Prevents accidental commits in read operations
Identifier assignment always via IdentifierAssigner
Centralizes collision handling
Soft-delete filtering always via exclude_deleted()
Prevents filtering logic from spreading into serializers or routers
New migrations must include a working downgrade()
Operational rollback capability
__abstract_resource__ must be set on every new model class
Prevents silent exclusion from deletion and trigger logic
Low-Level Design: AIOD REST API Database Layer Rearchitecture
Document Version: 1.0
Status: Draft for Review
Prepared By: Kartik Sapra
Date: April 2026
Table of Contents
1. Introduction
1.1 Purpose
This document provides a detailed low-level design for the rearchitecture of the database layer within the AIOD REST API platform. The design is informed by a systematic code review of the existing implementation and targets twelve confirmed deficiencies. It serves as the technical specification for all engineers working on the database layer during the rearchitecture phase.
1.2 Problem Statement
The current database layer has grown organically and carries several structural problems that now affect reliability, security, and operational confidence. Migrations cannot be auto-generated because Alembic's metadata reference is unset. Sessions do not roll back on failure. Passwords are embedded in plain-text connection strings. Identifiers are generated without collision protection. Hidden database sessions open inside the serialization layer during HTTP response rendering, causing untracked N+1 query patterns. Hard-deletion runs as a series of independent commits, meaning a mid-run failure leaves the database in a partially cleaned state. These are not theoretical risks; they are observable flaws in the current production path.
1.3 Scope
This document covers the following components:
versions/src/routers/1.4 Functional Requirements
downgrade()path.Submission.assetsproperty must not issue one query per asset.1.5 Non-Functional Requirements
pool_sizeof 10,max_overflowof 20, andpool_pre_pingenabled.AIoDConceptsubclass is abstract must be explicit and not break when a leaf class is subclassed for any reason.2. Architectural Context
2.1 High-Level Component Overview
The AIOD REST API database layer sits between the HTTP router layer and the MySQL database. It is composed of four logical subsystems: session management, ORM models, the migration chain, and operational utilities (deletion, review, authorization).
graph TD A[FastAPI Routers] --> B[Session Management] A --> C[Serialization Layer] B --> D[(MySQL Database)] C --> B E[ORM Model Layer] --> D F[Alembic Migrations] --> D G[Deletion Utilities] --> B H[Authorization Module] --> B I[Review Module] --> B C --> E A --> E2.2 Current vs. Proposed Architecture
The fundamental model hierarchy and relationship declaration system are sound and will be retained. What changes are the infrastructure wrappers around them: how sessions are created and closed, how identifiers are assigned, how the serializer resolves soft deletes, how the migration toolchain detects schema drift, and how the connection pool is configured.
graph LR subgraph Current S1[DbSession - no rollback] --> DB1[(MySQL)] ID1[random.choices - no retry] --> DB1 SER1[GetterDict opens new DbSession per item] --> DB1 HD1[hard_delete commits per concept] --> DB1 AL1[Alembic - target_metadata = None] -.->|no drift detection| DB1 end subgraph Proposed S2[DbSession - explicit rollback] --> DB2[(MySQL)] S2a[CommitDbSession - auto commit] --> DB2 ID2[assign_identifier - secrets + retry] --> DB2 SER2[with_loader_criteria - query-level filter] --> DB2 HD2[hard_delete - single transaction] --> DB2 AL2[Alembic - target_metadata = SQLModel.metadata] -->|drift detection| DB2 end3. Class and Object Design
3.1 Session Management
The two session types establish a clear contract:
DbSessionis for reads and operations where the caller controls commit timing, andCommitDbSessionis for write paths that should commit on success and roll back on any exception.classDiagram class EngineSingleton { -dict __monostate +Engine engine +__init__() +patch(engine: Engine) -_build_engine() Engine } class DbSession { +bool autoflush +__enter__() Session +__exit__(exc_type, exc_val, exc_tb) +rollback() void +close() void } class CommitDbSession { +bool autoflush +__enter__() Session +__exit__(exc_type, exc_val, exc_tb) +commit() void +rollback() void +close() void } class ConnectionConfig { +str drivername +str username +str password +str host +int port +str database +int pool_size +int max_overflow +int pool_timeout +int pool_recycle +bool pool_pre_ping +build_url() URL } EngineSingleton --> ConnectionConfig : reads DbSession --> EngineSingleton : uses engine CommitDbSession --|> DbSession : extendsKey Design Decision:
CommitDbSessioninherits fromDbSessionand overrides__exit__to callsession.commit()beforesession.close(). Both callsession.rollback()in the exception path. This makes the difference in behaviour explicit at the call site rather than implicit in the session lifecycle.3.2 Identifier Generation
classDiagram class IdentifierGenerator { -str prefix -str separator -int length +generate() str } class IdentifierAssigner { +assign_identifier(session, model_class, instance, max_retries: int) void -_is_taken(session, model_class, candidate: str) bool } IdentifierAssigner --> IdentifierGenerator : calls generate() IdentifierAssigner --> Session : queriesIdentifierAssigner.assign_identifier()calls_is_taken()before assigning the candidate to the instance. If the candidate is taken it generates a new one, up tomax_retriesattempts. After exhausting retries it raises a typedIdentifierExhaustionErrorrather than propagating a raw database constraint violation.3.3 Serialization Layer
classDiagram class Serializer { <<abstract>> +serialize(model) Any +value(model, attribute_name) Any } class AttributeSerializer { -str attribute_name +serialize(model) Any } class GetPathSerializer { -str path -Serializer inner_serializer +serialize(model) Any +value(model, attribute_name) Any } class DeSerializer { <<abstract>> +deserialize(session, serialized, user) Any } class FindByNameDeserializer { -type clazz +deserialize(session, name, user) int } class FindByIdentifierDeserializerList { -type clazz +deserialize(session, ids, user) list } class QueryOptions { +exclude_deleted() LoaderCriteria } Serializer <|-- AttributeSerializer Serializer <|-- GetPathSerializer DeSerializer <|-- FindByNameDeserializer DeSerializer <|-- FindByIdentifierDeserializerList QueryOptions ..> AIoDConcept : filters on date_deletedThe
QueryOptionsclass is a new addition. Itsexclude_deleted()method returns a SQLAlchemywith_loader_criteriaobject that the router applies once at query time, removing the need for any Python-side soft-delete filtering inside the serializer and eliminating hidden session opens entirely.3.4 Deletion Subsystem
classDiagram class HardDeleteService { +hard_delete_older_than(time_threshold: timedelta) void -_build_filter(concept, threshold: datetime) BinaryExpression } class DeletionTriggerFactory { +create_delete_triggers(parent_class) list~DDL~ +create_identifier_sync_triggers(dialect: str) list~DDL~ -create_deletion_trigger_one_to_one(...) DDL -create_deletion_trigger_many_to_one(...) DDL -create_deletion_trigger_many_to_many(...) DDL } HardDeleteService --> CommitDbSession : uses single transaction DeletionTriggerFactory --> EngineSingleton : reads dialect3.5 Authorization
classDiagram class User { +str subject_identifier } class Group { +int identifier +str name } class Permission { +int aiod_entry_identifier +str user_identifier +int group_identifier +PermissionType type_ } class PermissionType { <<enum>> READ WRITE ADMIN +__lt__(other) bool } class AIoDEntryORM { +int identifier +EntryStatus status +datetime date_modified +datetime date_created +list permissions } AIoDEntryORM "1" --> "*" Permission : has Permission "*" --> "0..1" User : granted to Permission "*" --> "0..1" Group : granted to Group "1" --> "*" User : contains PermissionType --> Permission : type ofA
CHECKconstraint enforces that exactly one ofuser_identifierorgroup_identifieris non-null on eachPermissionrow. This replaces the existing TODO comment with a proper implementation.4. Design Patterns
4.1 Singleton (Engine Management)
The
EngineSingletonuses the Borg pattern (shared state dictionary) to ensure one SQLAlchemy engine exists per process. This pattern is retained because it is correct and well implemented. The only change is adding explicit pool configuration parameters sourced fromconfig.default.tomlrather than relying on SQLAlchemy defaults.4.2 Context Manager (Session Lifecycle)
Python context managers (
__enter__/__exit__) are used to bind session lifecycle to code blocks. This pattern guarantees that rollback and close are called regardless of how the block exits. The existing implementation already uses this pattern correctly forDbSession; the change is adding rollback on exception and introducingCommitDbSessionas a dedicated write-path variant.4.3 Repository (Identifier Assignment)
IdentifierAssigneracts as a repository-style service that encapsulates the rules for creating a valid, unique identifier. Callers do not need to know anything about collision handling or retry logic; they callassign_identifier()and receive a guarantee that the instance now has a unique primary key or an exception is raised with a clear message.4.4 Strategy (Serializer / Deserializer)
The existing
SerializerandDeSerializerabstract base classes already implement the Strategy pattern. Each relationship field on a model declares its own serializer and deserializer strategy independently. This design is retained as-is.4.5 Decorator (Loader Criteria)
QueryOptions.exclude_deleted()wraps SQLAlchemy'swith_loader_criteriain a domain-specific helper that is applied as a decorator to any query involvingAIoDConceptrelationships. This keeps the soft-delete filtering policy in one place. Any future change to what "deleted" means (for example, adding anis_archivedstate) requires a change in one class.5. Module Interactions and Interfaces
5.1 Session Lifecycle Sequence
This diagram shows a typical write request, illustrating where commit and rollback now occur.
sequenceDiagram participant Router participant CommitDbSession participant Session participant Database Router->>CommitDbSession: enter context CommitDbSession->>Session: create Session(engine) CommitDbSession-->>Router: yields session Router->>Session: session.add(resource) Router->>Session: session.flush() Database-->>Session: flush result alt Success CommitDbSession->>Session: session.commit() Session->>Database: COMMIT else Exception raised CommitDbSession->>Session: session.rollback() Session->>Database: ROLLBACK CommitDbSession->>Router: re-raises exception end CommitDbSession->>Session: session.close()5.2 Identifier Assignment Sequence
sequenceDiagram participant Router participant IdentifierAssigner participant IdentifierGenerator participant Session participant Database Router->>IdentifierAssigner: assign_identifier(session, Dataset, instance) loop Up to max_retries IdentifierAssigner->>IdentifierGenerator: generate() IdentifierGenerator-->>IdentifierAssigner: "dset_Xk9mP..." IdentifierAssigner->>Session: session.get(Dataset, candidate) Session->>Database: SELECT WHERE identifier = ? Database-->>Session: None (no collision) IdentifierAssigner->>IdentifierAssigner: instance.identifier = candidate IdentifierAssigner-->>Router: returns end alt All retries exhausted IdentifierAssigner->>Router: raises IdentifierExhaustionError end5.3 Soft-Delete Filtering Sequence (Proposed)
This replaces the current approach of opening hidden sessions inside
GetterDict.get().sequenceDiagram participant Router participant Session participant QueryOptions participant Database participant Serializer Router->>QueryOptions: exclude_deleted() QueryOptions-->>Router: LoaderCriteria(date_deleted IS NULL) Router->>Session: exec(select(Dataset).options(exclude_deleted())) Session->>Database: SELECT ... WHERE date_deleted IS NULL (JOIN applied to relationships) Database-->>Session: Dataset rows with relationships pre-filtered Session-->>Router: list of Dataset instances Router->>Serializer: serialize(dataset_instance) Serializer-->>Router: response dict (no hidden DB calls)5.4 Hard-Delete Sequence (Proposed)
sequenceDiagram participant CronJob participant HardDeleteService participant CommitDbSession participant Session participant Database CronJob->>HardDeleteService: hard_delete_older_than(timedelta(minutes=60)) HardDeleteService->>CommitDbSession: enter context CommitDbSession->>Session: create Session loop For each AIoDConcept subclass HardDeleteService->>Session: exec(DELETE WHERE date_deleted < threshold) Session->>Database: DELETE statement (within transaction) end CommitDbSession->>Session: session.commit() Session->>Database: COMMIT (all deletes committed atomically) alt Any exception CommitDbSession->>Session: session.rollback() Session->>Database: ROLLBACK (no partial state) end6. Error Handling and Edge Cases
6.1 Exception Class Hierarchy
A typed exception hierarchy ensures that callers can catch precisely the errors they care about, rather than catching broad
Exceptionor relying on database constraint violations bubbling up as raw SQLAlchemy errors.classDiagram class AIoDDatabaseError { <<base>> +str message } class IdentifierExhaustionError { +str model_class_name +int max_retries } class DialectResolutionError { +str expected +str actual } class SessionDetachedError { +str operation } AIoDDatabaseError <|-- IdentifierExhaustionError AIoDDatabaseError <|-- DialectResolutionError AIoDDatabaseError <|-- SessionDetachedError6.2 Edge Case Handling
IntegrityError, 500 responseIdentifierAssignerretries up to 5 times, then raisesIdentifierExhaustionError, which maps to HTTP 503DbSession.__exit__callsrollback()beforeclose()unconditionally on exceptionSubmission.assetscalled on detached instanceNonesilently viaSession.object_session()SessionDetachedErrorraised with the operation nameROLLBACKviaCommitDbSession, no partial stateCHECKconstraint SQL generated silentlyDialectResolutionErrorraised at engine initialization, server fails fast7. Concurrency and Connection Management
7.1 Connection Pool Configuration
The current
EngineSingletonsets onlypool_recycle. The proposed configuration exposes all relevant pool parameters throughconfig.default.toml, with the following recommended defaults:pool_sizemax_overflowPoolTimeoutpool_timeoutpool_recyclepool_pre_pingSELECT 1before reuse to detect connections dropped by the MySQL server7.2 Session Thread Safety
Each request in FastAPI receives its own session instance via
get_session(). Sessions are never shared across threads. TheEngineSingletonis thread-safe becausecreate_engineis called only once (guarded by the Borg shared-state pattern) and the resulting engine's connection pool handles concurrent access internally.7.3 Alembic Migration Concurrency
Migrations are run as a single-threaded process against the live database before the API server starts. The migration chain uses
revisionanddown_revisionto enforce a linear order. Running two migration processes simultaneously would produce undefined behaviour; this is prevented at the infrastructure level by the Docker Compose startup sequence.8. Database Schema
8.1 Core Entity Relationship Diagram
erDiagram aiod_entry { int identifier PK enum status datetime date_modified datetime date_created } aiod_concept { varchar identifier PK str platform str platform_resource_identifier datetime date_deleted int aiod_entry_identifier FK } ai_resource { varchar identifier PK varchar aiod_concept_id FK } ai_asset { varchar identifier PK varchar ai_resource_id FK } dataset { varchar identifier PK varchar ai_asset_id FK } user { varchar subject_identifier PK } group { int identifier PK varchar name } permission { int identifier PK int aiod_entry_identifier FK varchar user_identifier FK int group_identifier FK enum type_ } submission { int identifier PK datetime request_date varchar requestee_identifier FK } review { int identifier PK enum decision datetime decision_date int submission_identifier FK varchar reviewer_identifier FK } platform { varchar name PK } aiod_concept ||--|| aiod_entry : "has" aiod_concept }o--|| platform : "sourced_from" ai_resource ||--|| aiod_concept : "is_a" ai_asset ||--|| ai_resource : "is_a" dataset ||--|| ai_asset : "is_a" aiod_entry ||--o{ permission : "governed_by" permission }o--o| user : "granted_to" permission }o--o| group : "granted_to" group }o--o{ user : "contains" submission }o--|| user : "requested_by" review }o--|| submission : "responds_to" review }o--|| user : "made_by"8.2 Identifier Design
Every
AIoDConceptsubclass uses a string primary key of the form{prefix}_{24 random alphanumeric characters}. The prefix is at most four characters and is declared as__abbreviation__on each model class (for example,dsetfor Dataset,mlmdfor MLModel). This makes identifiers human-readable in logs and API responses without requiring a join to resolve the resource type.The current
identifiercolumns use integer primary keys in intermediate tables (AIResourceORM,AIAssetTable, etc.) and are synchronized with theAIoDConceptstring identifier via SQLAFTER INSERTtriggers. This synchronization mechanism is retained as-is.8.3 Constraint Inventory
aiod_conceptplatform XOR platform_resource_identifieraiod_conceptplatform = LOWER(platform)aiod_concept(platform, platform_resource_identifier, coalesce(date_deleted,'2000-01-01'))named_relationname = LOWER(name)permissionuser_identifier XOR group_identifier NOT NULL8.4 Index Design
Beyond the constraints above, the following indexes support the most common query patterns:
aiod_conceptplatform,platform_resource_identifieraiod_conceptdate_deletednamed_relationnameaiod_entrystatus9. Security Considerations
9.1 Database Credential Handling
The connection URL is constructed using
sqlalchemy.engine.URL.create()instead of Python string interpolation. This object's__repr__and__str__methods mask the password, ensuring it does not appear in log output, tracebacks, or debug tooling even whenecho=Trueis enabled during development.Credentials themselves are sourced from
config.default.toml, which is excluded from version control via.gitignore. In production, the configuration values are injected through environment variables or a secrets manager mounted at the container level.9.2 Permission Enforcement
The
PermissionTypeenum defines a total ordering (READ < WRITE < ADMIN) enforced by__lt__. Authorization checks always specify a minimum required level usingat_least=PermissionType.WRITEand so on, rather than equality. This means that anADMINuser always satisfies aWRITEcheck without special-casing.The proposed
Groupexpansion adds no new attack surface because theCHECKconstraint at the database level prevents any permission row from simultaneously referencing a user and a group.9.3 Taxonomy Input Validation
Non-connector users are prevented from submitting arbitrary taxonomy terms by
FindByNameDeserializer, which checks theofficialflag onTaxonomyrows. This check happens in the deserialization layer before any data reaches the ORM, so it applies uniformly across all endpoints that accept taxonomy fields.Platform resource identifiers are validated against per-platform regex patterns (Zenodo, HuggingFace, OpenML) before storage. This prevents malformed external identifiers from entering the platform and simplifies connector reconciliation.
10. Performance and Scalability
10.1 Eliminating Hidden N+1 Queries
The most significant performance improvement in this rearchitecture is the removal of hidden database sessions from the serialization layer. The current
is_soft_deleted()function insidecreate_getter_dictopens a new session for every related item it checks. On a list endpoint returning 50 datasets, each with 10 relationships, this generates up to 500 hidden database roundtrips per response.The proposed
exclude_deleted()loader criteria moves this filter to the query level. The database engine applies it in a single join during the initialSELECT, and the serialization layer receives already-filtered data without touching the database again.10.2 Batched Asset Loading in Review
Submission.assetscurrently issues onesession.get()per asset identifier. The proposed implementation groups identifiers by asset type prefix and issues oneSELECT ... WHERE identifier IN (...)per type. For a typical submission involving five datasets and two ML models, this reduces seven queries to two.10.3 Caching of Computed Mappings
get_asset_type_by_abbreviation()is decorated with@functools.cachein the existing code and is correctly retained. The mapping from abbreviation prefix to model class is computed once at startup and reused. The proposed identifier assigner uses this same cache when resolving the target model class for collision detection.10.4 Scalability Boundary
The database layer itself is stateless at the application level: no in-memory query results are shared across requests. Horizontal scaling of the API tier requires only that all instances share the same MySQL database. The connection pool on each instance (10 base + 20 overflow = 30 max connections) must be factored into MySQL's
max_connectionssetting when planning capacity for multiple replicas.11. Maintainability and Documentation
11.1 Alembic Autogenerate
Enabling
target_metadata = SQLModel.metadatainalembic/env.pyallows engineers to runalembic revision --autogenerate -m "description"after any model change. Alembic compares the ORM metadata against the live schema and generates a migration stub that includes the detected differences. Engineers review and adjust the stub before committing. This replaces the current manual process where a developer must remember to write a migration and risk silent schema drift if they forget.All ORM table-backed modules must be imported before
target_metadatais referenced inenv.pyso that SQLAlchemy can register their table definitions. A module-level import block at the top ofenv.pywill enumerate these explicitly.11.2 Downgrade Discipline
A CI check will fail any pull request that adds a migration with a
pass-onlydowngrade(). For migrations where a true structural downgrade is impractical (large data migrations, type changes with data loss), thedowngrade()body must contain a comment explaining why a backup restore is required, and the migration must be tagged with a# no-downgrade-requiredmarker that the CI check accepts as a valid excuse.11.3 Abstract Resource Flag
The
__abstract_resource__class variable replaces the structural heuristic innon_abstract_subclasses(). Each model class explicitly declares whether it is concrete or abstract. This is documented in the developer README as a requirement for any new model added to the hierarchy.11.4 Coding Standards for This Layer
CommitDbSessionDbSessionIdentifierAssignerexclude_deleted()downgrade()__abstract_resource__must be set on every new model class