Sense — Data Model¶
Sense persists state in three places, each chosen for a specific shape of data.
| Store | Why | What lives there |
|---|---|---|
PostgreSQL axion_sense |
Transactional state, multi-tenant joins, spatial queries with PostGIS | Users, orgs, roles, tracks (logs), frame batches (lifecycle), holds, detour tasks, territories, projects, custom layers, WMS sources, Citylens mappings, OpenFGA model versions |
PostgreSQL axion_sense_tasks |
Hangfire's storage + worker-only domain | Hangfire job state, Citylens migration flag, worker auxiliary tables |
ClickHouse axion_sense |
High-volume immutable + analytics | tracks, frames, detections, audit_log |
Frame bytes never live in either DB — they're in S3. Postgres holds the lifecycle row; ClickHouse holds the searchable metadata.
Postgres ER (main entities)¶
erDiagram
USERS ||--o{ USER_ORG : "is in"
ORGANIZATIONS ||--o{ USER_ORG : "has"
USER_ORG ||--o{ USER_ORG_ROLE : "grants"
ROLES ||--o{ USER_ORG_ROLE : "is"
ORGANIZATIONS ||--o{ TRACK_LOGS : "scopes"
TRACK_LOGS ||--o{ FRAME_BATCHES : "has"
ORGANIZATIONS ||--o{ HOLDS : "owns"
ORGANIZATIONS ||--o{ DETOUR_TASKS : "owns"
PROJECTS ||--o{ DETOUR_TASKS : "groups"
TERRITORIES ||--o{ DETOUR_TASKS : "scopes"
ORGANIZATIONS ||--o{ CUSTOM_LAYERS : "owns"
ORGANIZATIONS ||--o{ WMS_SOURCES : "owns"
CUSTOM_LAYERS ||--o| PRIORITY_LAYERS : "is sometimes"
TRACK_LOGS ||--o| CITYLENS_TRACK_MAPPING : "may map to"
USERS {
bigint id PK "IDENTITY"
string external_auth_id UK
string email
string display_name
bool is_active
jsonb profile_data "indexed JSONB filters"
}
ORGANIZATIONS {
bigint id PK
string name
string code UK
}
USER_ORG {
bigint user_id FK
bigint organization_id FK
timestamptz expires_at
}
ROLES {
bigint id PK
string name
bigint inherits_from_id FK
}
USER_ORG_ROLE {
bigint user_id FK
bigint organization_id FK
bigint role_id FK
}
TRACK_LOGS {
uuid id PK "UUIDv7 = track_id"
bigint organization_id FK
bigint user_id FK
timestamptz created_at "PARTITION KEY (monthly)"
string s3_log_key "log/{id}"
}
FRAME_BATCHES {
uuid id PK
uuid track_id FK
string status "pending | committed"
timestamptz commit_time
timestamptz created_at "PARTITION KEY (monthly)"
}
HOLDS {
bigint id PK
bigint organization_id FK
geometry location "PointZ, SRID 4326"
text message
timestamptz created_at
}
DETOUR_TASKS {
bigint id PK
bigint organization_id FK
bigint project_id FK
bigint territory_id FK
string status "AVAILABLE..VERIFIED"
int priority
geometry geometry "WKB"
bigint assigned_driver_id FK
int percent_completed
timestamptz deadline
}
CUSTOM_LAYERS {
bigint id PK
bigint organization_id FK
string name
string s3_pmtiles_key
timestamptz updated_at
}
CITYLENS_TRACK_MAPPING {
string citylens_id PK
uuid axion_id UK
timestamptz created_at
}
Some columns shown in the diagram are summarized — the canonical schema is in
axion.sense.backend/src/Axion.Sense.Data/Entities/.
Partitioning (pg_partman)¶
| Table | Partition by | Granularity | Retention |
|---|---|---|---|
track_logs |
created_at |
Monthly | None (kept indefinitely) |
frame_batches |
created_at |
Monthly | None |
outbox (Citylens-side, not in axion_sense) |
created_at |
Daily | 7 days |
PartmanMaintenanceJob runs nightly: creates next month's partition, drops anything past retention. Custom PartitionedMigrationsSqlGenerator in EF makes new tables PARTITION BY RANGE aware so future pg_partman setup is one-line.
ClickHouse schema¶
| Table | Engine | Sort key | Partition | Notes |
|---|---|---|---|---|
tracks |
ReplicatedMergeTree |
(id) |
toYYYYMM(UUIDv7ToDateTime(id)) |
Route LineString, device flattened, distance, duration, frame count, h3_indices Array(UInt64), is_map_matched Bool |
frames |
ReplicatedMergeTree |
(track_id, captured_at, id) |
toYYYYMM(UUIDv7ToDateTime(id)) |
lat, lon, altitude, azimuth, speed, accuracy, h3_index_res9 UInt64, h3_index_res12 UInt64, is_uploaded Bool |
detections |
ReplicatedMergeTree |
(track_id, frame_id, detector_id) |
toYYYYMM(UUIDv7ToDateTime(track_id)) |
detections Array(JSON), classes Array(String), bounding boxes |
audit_log |
ReplicatedMergeTree (write-once) |
(organization_id, created_at) |
toYYYYMM(created_at) |
Actor, action, resource, request_id, JSONB context |
Skip indexes (bloom filters):
frames: onh3_index_res9,h3_index_res12,user_iddetections: onclasses Array(String)
Why plain ReplicatedMergeTree + client-side DELETE + INSERT — at-least-once delivery from Kafka and from the Citylens importer means duplicates can sneak in. We use writer-side DELETE by primary key (with mutations_sync = 1, allow_nondeterministic_mutations = 1) followed by INSERT so each row is replaced cleanly. ReplacingMergeTree was the earlier choice but the required SELECT … FINAL cost killed read latency under load; plain ReplicatedMergeTree keeps reads fast at the cost of a heavier write path.
Why UUIDv7ToDateTime — track and frame IDs are UUIDv7, so the timestamp is encoded in the ID. Partitioning by toYYYYMM(UUIDv7ToDateTime(id)) gives us time-ordered partitions with no extra timestamp column to maintain.
Hangfire schema (axion_sense_tasks)¶
Standard Hangfire.PostgreSql 1.21 schema: HangfireJob, HangfireJobParameter, HangfireState, HangfireCounter, HangfireHash, HangfireSet, HangfireList, HangfireServer. Plus the worker-only citylens_migration_state row.
Migrations¶
| Concern | Mechanism |
|---|---|
Postgres axion_sense |
EF Core migrations under src/Axion.Sense.Data/Migrations/. Custom PartitionedMigrationsSqlGenerator adds PARTITION BY RANGE for partman-managed tables. |
Postgres axion_sense_tasks |
EF Core migrations under src/Axion.Sense.Worker.Data/Migrations/. Run via Axion.Sense.Worker.dll --migrate. |
| ClickHouse | Idempotent CREATE TABLE IF NOT EXISTS SQL in deploy/clickhouse/init.sql. |
| OpenFGA | Migration runner detects model.json SHA-256 changes and writes a new authorization model version into the openfga_model_versions table. |