Skip to content

Commit 260a43a

Browse files
committed
Update object type spec for multi-store support
- Replace "Single Storage Backend" with "Default and Named Stores" - Add object@store syntax for named stores in table definitions - Add Named Stores configuration section with stores.<name> prefix - Update JSON schema with store, url, and path fields - Update Access Control Patterns for multiple buckets - Update orphan cleanup for per-store operation with delimiter-based listing for efficient Zarr enumeration
1 parent 052a40b commit 260a43a

File tree

1 file changed

+123
-26
lines changed

1 file changed

+123
-26
lines changed

docs/src/design/tables/object-type-spec.md

Lines changed: 123 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,41 @@ This is fundamentally different from **external references**, where DataJoint me
5050

5151
## Storage Architecture
5252

53-
### Single Storage Backend Per Pipeline
53+
### Default and Named Stores
5454

55-
Each DataJoint pipeline has **one** associated storage backend configured in `datajoint.json`. DataJoint fully controls the path structure within this backend.
55+
Each DataJoint pipeline has a **default storage backend** plus optional **named stores**, all configured in `datajoint.json`. DataJoint fully controls the path structure within each store.
5656

57-
**Why single backend?** The object store is a logical extension of the schema—its integrity must be verifiable as a unit. With a single backend:
58-
- Schema completeness can be verified with one listing operation
59-
- Orphan detection is straightforward
60-
- Migration requires only config changes, not mass URL updates in the database
57+
```python
58+
@schema
59+
class Recording(dj.Manual):
60+
definition = """
61+
subject_id : int
62+
session_id : int
63+
---
64+
raw_data : object # uses default store
65+
published : object@public # uses 'public' named store
66+
"""
67+
```
68+
69+
**All stores follow OAS principles:**
70+
- DataJoint owns the lifecycle (insert/delete/fetch as a unit)
71+
- Same deterministic path structure (`project/schema/Table/objects/...`)
72+
- Same access control alignment with database
73+
- Each store has its own `datajoint_store.json` metadata file
74+
75+
**Why support multiple stores?**
76+
- Different access policies (private vs public buckets)
77+
- Different storage tiers (hot vs cold storage)
78+
- Organizational requirements (data sovereignty, compliance)
79+
80+
**Why require explicit store configuration?**
81+
- All stores must be registered for OAS semantics
82+
- Credential management aligns with database access control (platform-managed)
83+
- Orphan cleanup operates per-store with full knowledge of configured stores
6184

6285
### Access Control Patterns
6386

64-
The deterministic path structure (`project/schema/Table/objects/pk=val/...`) enables **prefix-based access control policies** on the storage backend.
87+
The deterministic path structure (`project/schema/Table/objects/pk=val/...`) enables **prefix-based access control policies** on each storage backend.
6588

6689
**Supported access control levels:**
6790

@@ -72,21 +95,23 @@ The deterministic path structure (`project/schema/Table/objects/pk=val/...`) ena
7295
| Table-level | IAM/bucket policy | `my-bucket/my_project/schema/SensitiveTable/*` |
7396
| Row-level | Per-object ACL or signed URLs | Future enhancement |
7497

75-
**Example: Private and public data in one bucket**
76-
77-
Rather than using separate buckets, use prefix-based policies:
98+
**Example: Private and public data in separate stores**
7899

79100
```
80-
s3://my-bucket/my_project/
81-
├── internal_schema/ ← restricted IAM policy
82-
│ └── ProcessingResults/
83-
│ └── objects/...
84-
└── publications/ ← public bucket policy
101+
# Default store (private)
102+
s3://internal-bucket/my_project/
103+
└── lab_schema/
104+
└── ProcessingResults/
105+
└── objects/...
106+
107+
# Named 'public' store
108+
s3://public-bucket/my_project/
109+
└── lab_schema/
85110
└── PublishedDatasets/
86111
└── objects/...
87112
```
88113

89-
This achieves the same access separation as multiple buckets while maintaining schema integrity in a single backend.
114+
Alternatively, use prefix-based policies within a single bucket if preferred.
90115

91116
**Row-level access control** (access to objects for specific primary key values) is not directly supported by object store policies. Future versions may address this via DataJoint-generated signed URLs that project database permissions onto object access.
92117

@@ -156,6 +181,42 @@ For local filesystem storage:
156181
}
157182
```
158183

184+
### Named Stores
185+
186+
Additional stores can be defined using the `object_storage.stores.<name>` prefix:
187+
188+
```json
189+
{
190+
"object_storage.project_name": "my_project",
191+
"object_storage.protocol": "s3",
192+
"object_storage.bucket": "internal-bucket",
193+
"object_storage.location": "my_project",
194+
195+
"object_storage.stores.public.protocol": "s3",
196+
"object_storage.stores.public.bucket": "public-bucket",
197+
"object_storage.stores.public.location": "my_project"
198+
}
199+
```
200+
201+
Named stores inherit `project_name` from the default configuration but can override all other settings. Use named stores with the `object@store_name` syntax:
202+
203+
```python
204+
@schema
205+
class Dataset(dj.Manual):
206+
definition = """
207+
dataset_id : int
208+
---
209+
internal_data : object # default store (internal-bucket)
210+
published_data : object@public # public store (public-bucket)
211+
"""
212+
```
213+
214+
Each named store:
215+
- Must be explicitly configured (no ad-hoc URLs)
216+
- Has its own `datajoint_store.json` metadata file
217+
- Follows the same OAS lifecycle semantics as the default store
218+
- Credentials are managed at the platform level, aligned with database access control
219+
159220
### Settings Schema
160221

161222
| Setting | Type | Required | Description |
@@ -320,20 +381,24 @@ class Recording(dj.Manual):
320381
subject_id : int
321382
session_id : int
322383
---
323-
raw_data : object # managed file storage
324-
processed : object # another object attribute
384+
raw_data : object # uses default store
385+
processed : object # another object attribute (default store)
386+
published : object@public # uses named 'public' store
325387
"""
326388
```
327389

328-
Note: No `@store` suffix needed - storage is determined by pipeline configuration.
390+
- `object` — uses the default storage backend
391+
- `object@store_name` — uses a named store (must be configured in settings)
329392

330393
## Database Storage
331394

332395
The `object` type is stored as a `JSON` column in MySQL containing:
333396

334-
**File example:**
397+
**File in default store:**
335398
```json
336399
{
400+
"store": null,
401+
"url": "s3://my-bucket/my_project/my_schema/Recording/objects/subject_id=123/session_id=45/raw_data_Ax7bQ2kM.dat",
337402
"path": "my_schema/Recording/objects/subject_id=123/session_id=45/raw_data_Ax7bQ2kM.dat",
338403
"size": 12345,
339404
"hash": null,
@@ -344,10 +409,12 @@ The `object` type is stored as a `JSON` column in MySQL containing:
344409
}
345410
```
346411

347-
**File with optional hash:**
412+
**File in named store:**
348413
```json
349414
{
350-
"path": "my_schema/Recording/objects/subject_id=123/session_id=45/raw_data_Ax7bQ2kM.dat",
415+
"store": "public",
416+
"url": "s3://public-bucket/my_project/my_schema/Dataset/objects/dataset_id=1/published_data_Bx8cD3kM.dat",
417+
"path": "my_schema/Dataset/objects/dataset_id=1/published_data_Bx8cD3kM.dat",
351418
"size": 12345,
352419
"hash": "sha256:abcdef1234...",
353420
"ext": ".dat",
@@ -360,6 +427,8 @@ The `object` type is stored as a `JSON` column in MySQL containing:
360427
**Folder example:**
361428
```json
362429
{
430+
"store": null,
431+
"url": "s3://my-bucket/my_project/my_schema/Recording/objects/subject_id=123/session_id=45/raw_data_pL9nR4wE",
363432
"path": "my_schema/Recording/objects/subject_id=123/session_id=45/raw_data_pL9nR4wE",
364433
"size": 567890,
365434
"hash": null,
@@ -373,6 +442,8 @@ The `object` type is stored as a `JSON` column in MySQL containing:
373442
**Zarr example (large dataset, metadata fields omitted for performance):**
374443
```json
375444
{
445+
"store": null,
446+
"url": "s3://my-bucket/my_project/my_schema/Recording/objects/subject_id=123/session_id=45/neural_data_kM3nP2qR.zarr",
376447
"path": "my_schema/Recording/objects/subject_id=123/session_id=45/neural_data_kM3nP2qR.zarr",
377448
"size": null,
378449
"hash": null,
@@ -386,7 +457,9 @@ The `object` type is stored as a `JSON` column in MySQL containing:
386457

387458
| Field | Type | Required | Description |
388459
|-------|------|----------|-------------|
389-
| `path` | string | Yes | Full path/key within storage backend (includes token) |
460+
| `store` | string/null | Yes | Store name (e.g., `"public"`), or `null` for default store |
461+
| `url` | string | Yes | Full URL including protocol and bucket (e.g., `s3://bucket/path`) |
462+
| `path` | string | Yes | Relative path within store (excludes protocol/bucket, includes token) |
390463
| `size` | integer/null | No | Total size in bytes (sum for folders), or null if not computed. See [Performance Considerations](#performance-considerations). |
391464
| `hash` | string/null | Yes | Content hash with algorithm prefix, or null (default) |
392465
| `ext` | string/null | Yes | File extension as tooling hint (e.g., `.dat`, `.zarr`) or null. See [Extension Field](#extension-field). |
@@ -395,6 +468,11 @@ The `object` type is stored as a `JSON` column in MySQL containing:
395468
| `mime_type` | string | No | MIME type (files only, auto-detected from extension) |
396469
| `item_count` | integer | No | Number of files (folders only), or null if not computed. See [Performance Considerations](#performance-considerations). |
397470

471+
**Why both `url` and `path`?**
472+
- `url`: Self-describing, enables cross-validation, robust to config changes
473+
- `path`: Enables store name re-derivation at migration time, consistent structure across stores
474+
- At migration, the store name can be derived by matching `url` against configured stores
475+
398476
### Extension Field
399477

400478
The `ext` field is a **tooling hint** that preserves the original file extension or provides a conventional suffix for directory-based formats. It is:
@@ -937,18 +1015,36 @@ Orphaned files (files in storage without corresponding database records) may acc
9371015

9381016
### Orphan Cleanup Procedure
9391017

940-
Orphan cleanup is a **separate maintenance operation** provided via the `schema.object_storage` utility object.
1018+
Orphan cleanup is a **separate maintenance operation** provided via the `schema.object_storage` utility object. Cleanup operates **per-store**, iterating through all configured stores.
9411019

9421020
```python
9431021
# Maintenance utility methods (not a hidden table)
944-
schema.object_storage.find_orphaned(grace_period_minutes=30) # List orphaned files
1022+
schema.object_storage.find_orphaned(grace_period_minutes=30) # List orphaned files (all stores)
1023+
schema.object_storage.find_orphaned(store="public") # List orphaned files (specific store)
9451024
schema.object_storage.cleanup_orphaned(dry_run=True) # Delete orphaned files
9461025
schema.object_storage.verify_integrity() # Check all objects exist
9471026
schema.object_storage.stats() # Storage usage statistics
9481027
```
9491028

9501029
**Note**: `schema.object_storage` is a utility object, not a hidden table. Unlike `attach@store` which uses `~external_*` tables, the `object` type stores all metadata inline in JSON columns and has no hidden tables.
9511030

1031+
**Efficient listing for Zarr and large stores:**
1032+
1033+
For stores with Zarr arrays (potentially millions of chunk objects), cleanup uses **delimiter-based listing** to enumerate only root object names, not individual chunks:
1034+
1035+
```python
1036+
# S3 API with delimiter - lists "directories" only
1037+
response = s3.list_objects_v2(
1038+
Bucket=bucket,
1039+
Prefix='project/schema/Table/objects/',
1040+
Delimiter='/'
1041+
)
1042+
# Returns: ['neural_data_kM3nP2qR.zarr/', 'raw_data_Ax7bQ2kM.dat']
1043+
# NOT millions of individual chunk keys
1044+
```
1045+
1046+
Orphan deletion uses recursive delete to remove entire Zarr stores efficiently.
1047+
9521048
**Grace period for in-flight inserts:**
9531049

9541050
While random tokens prevent filename collisions, there's a race condition with in-flight inserts:
@@ -962,8 +1058,9 @@ While random tokens prevent filename collisions, there's a race condition with i
9621058
**Solution**: The `grace_period_minutes` parameter (default: 30) excludes files created within that window, assuming they are in-flight inserts.
9631059

9641060
**Important considerations:**
1061+
- Cleanup enumerates all configured stores (default + named)
1062+
- Uses delimiter-based listing for efficiency with Zarr stores
9651063
- Grace period handles race conditions—cleanup is safe to run anytime
966-
- Running during low-activity periods reduces in-flight operations to reason about
9671064
- `dry_run=True` previews deletions before execution
9681065
- Compares storage contents against JSON metadata in table columns
9691066

0 commit comments

Comments
 (0)