Skip to content

Commit 4a43f50

Browse files
Merge pre/v2.0
Co-authored-by: dimitri-yatsenko <dimitri@datajoint.com>
2 parents bab4db8 + 895259f commit 4a43f50

File tree

7 files changed

+318
-48
lines changed

7 files changed

+318
-48
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

src/datajoint/declare.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
INTERNAL_ATTACH=r"attach$",
6666
EXTERNAL_ATTACH=r"attach@(?P<store>[a-z][\-\w]*)$",
6767
FILEPATH=r"filepath@(?P<store>[a-z][\-\w]*)$",
68-
OBJECT=r"object$", # managed object storage (files/folders)
68+
OBJECT=r"object(@(?P<store>[a-z][\-\w]*))?$", # managed object storage (files/folders)
6969
UUID=r"uuid$",
7070
ADAPTED=r"<.+>$",
7171
).items()
@@ -469,6 +469,9 @@ def substitute_special_type(match, category, foreign_key_sql, context):
469469
match["type"] = "LONGBLOB"
470470
elif category == "OBJECT":
471471
# Object type stores metadata as JSON - no foreign key to external table
472+
# Extract store name if present (object@store_name syntax)
473+
if "@" in match["type"]:
474+
match["store"] = match["type"].split("@", 1)[1]
472475
match["type"] = "JSON"
473476
elif category in EXTERNAL_TYPES:
474477
if category == "FILEPATH" and not _support_filepath_types():

src/datajoint/fetch.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@ def _get(connection, attr, data, squeeze, download_path):
5353
if attr.is_object:
5454
# Object type - return ObjectRef handle
5555
json_data = json.loads(data) if isinstance(data, str) else data
56+
# Get the correct backend based on store name in metadata
57+
store_name = json_data.get("store") # None for default store
5658
try:
57-
spec = config.get_object_storage_spec()
59+
spec = config.get_object_store_spec(store_name)
5860
backend = StorageBackend(spec)
5961
except DataJointError:
6062
backend = None

src/datajoint/heading.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,13 @@ def _init_from_database(self):
359359
{env} = TRUE or upgrade datajoint.
360360
""".format(env=FILEPATH_FEATURE_SWITCH)
361361
)
362+
# Extract store name for external types and object types with named stores
363+
store = None
364+
if category in EXTERNAL_TYPES:
365+
store = attr["type"].split("@")[1]
366+
elif category == "OBJECT" and "@" in attr["type"]:
367+
store = attr["type"].split("@")[1]
368+
362369
attr.update(
363370
unsupported=False,
364371
is_attachment=category in ("INTERNAL_ATTACH", "EXTERNAL_ATTACH"),
@@ -368,7 +375,7 @@ def _init_from_database(self):
368375
is_blob=category in ("INTERNAL_BLOB", "EXTERNAL_BLOB"),
369376
uuid=category == "UUID",
370377
is_external=category in EXTERNAL_TYPES,
371-
store=(attr["type"].split("@")[1] if category in EXTERNAL_TYPES else None),
378+
store=store,
372379
)
373380

374381
if attr["in_key"] and any(

src/datajoint/objectref.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ class ObjectRef:
3434
from the storage backend.
3535
3636
Attributes:
37-
path: Full path/key within storage backend (includes token)
37+
path: Relative path within the store (includes token)
38+
url: Full URI to the object (e.g., 's3://bucket/path/to/object.dat')
39+
store: Store name (None for default store)
3840
size: Total size in bytes (sum for folders), or None if not computed.
3941
For large hierarchical data like Zarr stores, size computation can
4042
be expensive and is optional.
@@ -53,6 +55,8 @@ class ObjectRef:
5355
ext: str | None
5456
is_dir: bool
5557
timestamp: datetime
58+
url: str | None = None
59+
store: str | None = None
5660
mime_type: str | None = None
5761
item_count: int | None = None
5862
_backend: StorageBackend | None = None
@@ -80,6 +84,8 @@ def from_json(cls, json_data: dict | str, backend: StorageBackend | None = None)
8084

8185
return cls(
8286
path=data["path"],
87+
url=data.get("url"),
88+
store=data.get("store"),
8389
size=data["size"],
8490
hash=data.get("hash"),
8591
ext=data.get("ext"),
@@ -105,6 +111,10 @@ def to_json(self) -> dict:
105111
"is_dir": self.is_dir,
106112
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
107113
}
114+
if self.url:
115+
data["url"] = self.url
116+
if self.store:
117+
data["store"] = self.store
108118
if self.mime_type:
109119
data["mime_type"] = self.mime_type
110120
if self.item_count is not None:
@@ -121,7 +131,9 @@ def to_dict(self) -> dict:
121131
122132
Returns:
123133
Dict containing the object metadata:
124-
- path: Storage path
134+
- path: Relative storage path within the store
135+
- url: Full URI (e.g., 's3://bucket/path') (optional)
136+
- store: Store name (optional, None for default store)
125137
- size: File/folder size in bytes (or None)
126138
- hash: Content hash (or None)
127139
- ext: File extension (or None)
@@ -152,12 +164,15 @@ def fs(self) -> fsspec.AbstractFileSystem:
152164
return self._backend.fs
153165

154166
@property
155-
def store(self) -> fsspec.FSMap:
167+
def fsmap(self) -> fsspec.FSMap:
156168
"""
157169
Return FSMap suitable for Zarr/xarray.
158170
159171
This provides a dict-like interface to the storage location,
160172
compatible with zarr.open() and xarray.open_zarr().
173+
174+
Example:
175+
>>> z = zarr.open(obj_ref.fsmap, mode='r')
161176
"""
162177
self._ensure_backend()
163178
full_path = self._backend._full_path(self.path)

0 commit comments

Comments
 (0)