Skip to content

Commit bab4db8

Browse files
committed
Merge claude/upgrade-adapted-type-1W3ap
2 parents 0a5f3a9 + 8091225 commit bab4db8

File tree

4 files changed

+201
-21
lines changed

4 files changed

+201
-21
lines changed

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

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,42 @@ This is fundamentally different from **external references**, where DataJoint me
5454

5555
Each DataJoint pipeline has **one** associated storage backend configured in `datajoint.json`. DataJoint fully controls the path structure within this backend.
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
61+
62+
### Access Control Patterns
63+
64+
The deterministic path structure (`project/schema/Table/objects/pk=val/...`) enables **prefix-based access control policies** on the storage backend.
65+
66+
**Supported access control levels:**
67+
68+
| Level | Implementation | Example Policy Prefix |
69+
|-------|---------------|----------------------|
70+
| Project-level | IAM/bucket policy | `my-bucket/my_project/*` |
71+
| Schema-level | IAM/bucket policy | `my-bucket/my_project/lab_internal/*` |
72+
| Table-level | IAM/bucket policy | `my-bucket/my_project/schema/SensitiveTable/*` |
73+
| Row-level | Per-object ACL or signed URLs | Future enhancement |
74+
75+
**Example: Private and public data in one bucket**
76+
77+
Rather than using separate buckets, use prefix-based policies:
78+
79+
```
80+
s3://my-bucket/my_project/
81+
├── internal_schema/ ← restricted IAM policy
82+
│ └── ProcessingResults/
83+
│ └── objects/...
84+
└── publications/ ← public bucket policy
85+
└── PublishedDatasets/
86+
└── objects/...
87+
```
88+
89+
This achieves the same access separation as multiple buckets while maintaining schema integrity in a single backend.
90+
91+
**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.
92+
5793
### Supported Backends
5894

5995
DataJoint uses **[`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/)** to ensure compatibility across multiple storage backends:
@@ -134,6 +170,20 @@ For local filesystem storage:
134170
| `object_storage.access_key` | string | For cloud | Access key (can use secrets file) |
135171
| `object_storage.secret_key` | string | For cloud | Secret key (can use secrets file) |
136172

173+
### Configuration Immutability
174+
175+
**CRITICAL**: Once a project has been instantiated (i.e., `datajoint_store.json` has been created and the first object stored), the following settings MUST NOT be changed:
176+
177+
- `object_storage.project_name`
178+
- `object_storage.protocol`
179+
- `object_storage.bucket`
180+
- `object_storage.location`
181+
- `object_storage.partition_pattern`
182+
183+
Changing these settings after objects have been stored will result in **broken references**—existing paths stored in the database will no longer resolve to valid storage locations.
184+
185+
DataJoint validates `project_name` against `datajoint_store.json` on connect, but administrators must ensure other settings remain consistent across all clients for the lifetime of the project.
186+
137187
### Environment Variables
138188

139189
Settings can be overridden via environment variables:
@@ -210,9 +260,16 @@ s3://bucket/my_project/datajoint_store.json
210260
| `format_version` | string | Yes | Store format version for compatibility |
211261
| `datajoint_version` | string | Yes | DataJoint version that created the store |
212262
| `database_host` | string | No | Database server hostname (for bidirectional mapping) |
213-
| `database_name` | string | No | Database name (for bidirectional mapping) |
263+
| `database_name` | string | No | Database name on the server (for bidirectional mapping) |
264+
265+
The `database_name` field exists for DBMS platforms that support multiple databases on a single server (e.g., PostgreSQL, MySQL). The object storage configuration is **shared across all schemas comprising the pipeline**—it's a pipeline-level setting, not a per-schema setting.
266+
267+
The optional `database_host` and `database_name` fields enable bidirectional mapping between object stores and databases:
214268

215-
The optional `database_host` and `database_name` fields enable bidirectional mapping between object stores and databases. This is informational only - not enforced at runtime. Administrators can alternatively ensure unique `project_name` values across their namespace, and managed platforms may handle this mapping externally.
269+
- **Forward**: Client settings → object store location
270+
- **Reverse**: Object store metadata → originating database
271+
272+
This is informational only—not enforced at runtime. Administrators can alternatively ensure unique `project_name` values across their namespace, and managed platforms may handle this mapping externally.
216273

217274
### Store Initialization
218275

@@ -362,19 +419,28 @@ For large hierarchical data like Zarr stores, computing certain metadata can be
362419

363420
By default, **no content hash is computed** to avoid performance overhead for large objects. Storage backend integrity is trusted.
364421

365-
**Optional hashing** can be requested per-insert:
422+
**Explicit hash control** via insert kwarg:
366423

367424
```python
368425
# Default - no hash (fast)
369426
Recording.insert1({..., "raw_data": "/path/to/large.dat"})
370427

371-
# Request hash computation
428+
# Explicit hash request - user specifies algorithm
372429
Recording.insert1({..., "raw_data": "/path/to/important.dat"}, hash="sha256")
430+
431+
# Other supported algorithms
432+
Recording.insert1({..., "raw_data": "/path/to/data.bin"}, hash="md5")
433+
Recording.insert1({..., "raw_data": "/path/to/large.bin"}, hash="xxhash") # xxh3, faster for large files
373434
```
374435

375-
Supported hash algorithms: `sha256`, `md5`, `xxhash` (xxh3, faster for large files)
436+
**Design principles:**
376437

377-
**Staged inserts never compute hashes** - data is written directly to storage without a local copy to hash.
438+
- **Explicit over implicit**: No automatic hashing based on file size or other heuristics
439+
- **User controls the tradeoff**: User decides when integrity verification is worth the performance cost
440+
- **Files only**: Hash applies to files, not folders (folders use manifests for integrity)
441+
- **Staged inserts**: Hash is always `null` regardless of kwarg—data flows directly to storage without a local copy to hash
442+
443+
Supported hash algorithms: `sha256`, `md5`, `xxhash` (xxh3, faster for large files)
378444

379445
### Folder Manifests
380446

@@ -654,7 +720,7 @@ Remote URLs are detected by protocol prefix and handled via fsspec:
654720
2. Generate deterministic storage path with random token
655721
3. **Copy content to storage backend** via `fsspec`
656722
4. **If copy fails: abort insert** (no database operation attempted)
657-
5. Compute content hash (SHA-256)
723+
5. Compute content hash if requested (optional, default: no hash)
658724
6. Build JSON metadata structure
659725
7. Execute database INSERT
660726

@@ -758,7 +824,7 @@ class StagedInsert:
758824
│ 4. User assigns object references to staged.rec │
759825
├─────────────────────────────────────────────────────────┤
760826
│ 5. On context exit (success): │
761-
│ - Compute metadata (size, hash, item_count)
827+
│ - Build metadata (size/item_count optional, no hash)
762828
│ - Execute database INSERT │
763829
├─────────────────────────────────────────────────────────┤
764830
│ 6. On context exit (exception): │
@@ -839,7 +905,7 @@ Since storage backends don't support distributed transactions with MySQL, DataJo
839905
│ 2. Copy file/folder to storage backend │
840906
│ └─ On failure: raise error, INSERT not attempted │
841907
├─────────────────────────────────────────────────────────┤
842-
│ 3. Compute hash and build JSON metadata
908+
│ 3. Compute hash (if requested) and build JSON metadata │
843909
├─────────────────────────────────────────────────────────┤
844910
│ 4. Execute database INSERT │
845911
│ └─ On failure: orphaned file remains (acceptable) │
@@ -871,19 +937,35 @@ Orphaned files (files in storage without corresponding database records) may acc
871937

872938
### Orphan Cleanup Procedure
873939

874-
Orphan cleanup is a **separate maintenance operation** that must be performed during maintenance windows to avoid race conditions with concurrent inserts.
940+
Orphan cleanup is a **separate maintenance operation** provided via the `schema.object_storage` utility object.
875941

876942
```python
877-
# Maintenance utility methods
878-
schema.file_storage.find_orphaned() # List files not referenced in DB
879-
schema.file_storage.cleanup_orphaned() # Delete orphaned files
943+
# Maintenance utility methods (not a hidden table)
944+
schema.object_storage.find_orphaned(grace_period_minutes=30) # List orphaned files
945+
schema.object_storage.cleanup_orphaned(dry_run=True) # Delete orphaned files
946+
schema.object_storage.verify_integrity() # Check all objects exist
947+
schema.object_storage.stats() # Storage usage statistics
880948
```
881949

950+
**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.
951+
952+
**Grace period for in-flight inserts:**
953+
954+
While random tokens prevent filename collisions, there's a race condition with in-flight inserts:
955+
956+
1. Insert starts: file copied to storage with token `Ax7bQ2kM`
957+
2. Orphan cleanup runs: lists storage, queries DB for references
958+
3. File `Ax7bQ2kM` not yet in DB (INSERT not committed)
959+
4. Cleanup identifies it as orphan and deletes it
960+
5. Insert commits: DB now references deleted file!
961+
962+
**Solution**: The `grace_period_minutes` parameter (default: 30) excludes files created within that window, assuming they are in-flight inserts.
963+
882964
**Important considerations:**
883-
- Should be run during low-activity periods
884-
- Uses transactions or locking to avoid race conditions with concurrent inserts
885-
- Files recently uploaded (within a grace period) are excluded to handle in-flight inserts
886-
- Provides dry-run mode to preview deletions before execution
965+
- Grace period handles race conditions—cleanup is safe to run anytime
966+
- Running during low-activity periods reduces in-flight operations to reason about
967+
- `dry_run=True` previews deletions before execution
968+
- Compares storage contents against JSON metadata in table columns
887969

888970
## Fetch Behavior
889971

@@ -1291,3 +1373,4 @@ arr = da.from_zarr(obj_ref.store, component='spikes')
12911373
- [ ] Checksum verification on fetch
12921374
- [ ] Cache layer for frequently accessed files
12931375
- [ ] Parallel upload/download for large folders
1376+
- [ ] Row-level object access control via signed URLs (project DB permissions onto object access)

src/datajoint/heading.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ def secondary_attributes(self):
167167
def blobs(self):
168168
return [k for k, v in self.attributes.items() if v.is_blob]
169169

170+
@property
171+
def objects(self):
172+
return [k for k, v in self.attributes.items() if v.is_object]
173+
170174
@property
171175
def non_blobs(self):
172176
return [

src/datajoint/objectref.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ def to_json(self) -> dict:
111111
data["item_count"] = self.item_count
112112
return data
113113

114+
def to_dict(self) -> dict:
115+
"""
116+
Return the raw JSON metadata as a dictionary.
117+
118+
This is useful for inspecting the stored metadata without triggering
119+
any storage backend operations. The returned dict matches the JSON
120+
structure stored in the database.
121+
122+
Returns:
123+
Dict containing the object metadata:
124+
- path: Storage path
125+
- size: File/folder size in bytes (or None)
126+
- hash: Content hash (or None)
127+
- ext: File extension (or None)
128+
- is_dir: True if folder
129+
- timestamp: Upload timestamp
130+
- mime_type: MIME type (files only, optional)
131+
- item_count: Number of files (folders only, optional)
132+
"""
133+
return self.to_json()
134+
114135
def _ensure_backend(self):
115136
"""Ensure storage backend is available for I/O operations."""
116137
if self._backend is None:

src/datajoint/preview.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,84 @@
11
"""methods for generating previews of query expression results in python command line and Jupyter"""
22

3+
import json
4+
35
from .settings import config
46

57

8+
def _format_object_display(json_data):
9+
"""Format object metadata for display in query results."""
10+
if json_data is None:
11+
return "=OBJ[null]="
12+
if isinstance(json_data, str):
13+
try:
14+
json_data = json.loads(json_data)
15+
except (json.JSONDecodeError, TypeError):
16+
return "=OBJ=?"
17+
ext = json_data.get("ext")
18+
is_dir = json_data.get("is_dir", False)
19+
if ext:
20+
return f"=OBJ[{ext}]="
21+
elif is_dir:
22+
return "=OBJ[folder]="
23+
else:
24+
return "=OBJ[file]="
25+
26+
627
def preview(query_expression, limit, width):
728
heading = query_expression.heading
829
rel = query_expression.proj(*heading.non_blobs)
30+
object_fields = heading.objects
931
if limit is None:
1032
limit = config["display.limit"]
1133
if width is None:
1234
width = config["display.width"]
1335
tuples = rel.fetch(limit=limit + 1, format="array")
1436
has_more = len(tuples) > limit
1537
tuples = tuples[:limit]
38+
39+
# Fetch object field JSON data for display (raw JSON, not ObjectRef)
40+
object_data_list = []
41+
if object_fields:
42+
# Fetch primary key and object fields as dicts
43+
obj_rel = query_expression.proj(*object_fields)
44+
obj_tuples = obj_rel.fetch(limit=limit, format="array")
45+
for obj_tup in obj_tuples:
46+
obj_dict = {}
47+
for field in object_fields:
48+
if field in obj_tup.dtype.names:
49+
obj_dict[field] = obj_tup[field]
50+
object_data_list.append(obj_dict)
51+
1652
columns = heading.names
53+
54+
def get_placeholder(f):
55+
if f in object_fields:
56+
return "=OBJ[.xxx]="
57+
return "=BLOB="
58+
1759
widths = {
1860
f: min(
19-
max([len(f)] + [len(str(e)) for e in tuples[f]] if f in tuples.dtype.names else [len("=BLOB=")]) + 4,
61+
max([len(f)] + [len(str(e)) for e in tuples[f]] if f in tuples.dtype.names else [len(get_placeholder(f))]) + 4,
2062
width,
2163
)
2264
for f in columns
2365
}
2466
templates = {f: "%%-%d.%ds" % (widths[f], widths[f]) for f in columns}
67+
68+
def get_display_value(tup, f, idx):
69+
if f in tup.dtype.names:
70+
return tup[f]
71+
elif f in object_fields and idx < len(object_data_list):
72+
return _format_object_display(object_data_list[idx].get(f))
73+
else:
74+
return "=BLOB="
75+
2576
return (
2677
" ".join([templates[f] % ("*" + f if f in rel.primary_key else f) for f in columns])
2778
+ "\n"
2879
+ " ".join(["+" + "-" * (widths[column] - 2) + "+" for column in columns])
2980
+ "\n"
30-
+ "\n".join(" ".join(templates[f] % (tup[f] if f in tup.dtype.names else "=BLOB=") for f in columns) for tup in tuples)
81+
+ "\n".join(" ".join(templates[f] % get_display_value(tup, f, idx) for f in columns) for idx, tup in enumerate(tuples))
3182
+ ("\n ...\n" if has_more else "\n")
3283
+ (" (Total: %d)\n" % len(rel) if config["display.show_tuple_count"] else "")
3384
)
@@ -36,11 +87,32 @@ def preview(query_expression, limit, width):
3687
def repr_html(query_expression):
3788
heading = query_expression.heading
3889
rel = query_expression.proj(*heading.non_blobs)
90+
object_fields = heading.objects
3991
info = heading.table_status
4092
tuples = rel.fetch(limit=config["display.limit"] + 1, format="array")
4193
has_more = len(tuples) > config["display.limit"]
4294
tuples = tuples[0 : config["display.limit"]]
4395

96+
# Fetch object field JSON data for display (raw JSON, not ObjectRef)
97+
object_data_list = []
98+
if object_fields:
99+
obj_rel = query_expression.proj(*object_fields)
100+
obj_tuples = obj_rel.fetch(limit=config["display.limit"], format="array")
101+
for obj_tup in obj_tuples:
102+
obj_dict = {}
103+
for field in object_fields:
104+
if field in obj_tup.dtype.names:
105+
obj_dict[field] = obj_tup[field]
106+
object_data_list.append(obj_dict)
107+
108+
def get_html_display_value(tup, name, idx):
109+
if name in tup.dtype.names:
110+
return tup[name]
111+
elif name in object_fields and idx < len(object_data_list):
112+
return _format_object_display(object_data_list[idx].get(name))
113+
else:
114+
return "=BLOB="
115+
44116
css = """
45117
<style type="text/css">
46118
.Table{
@@ -120,8 +192,8 @@ def repr_html(query_expression):
120192
ellipsis="<p>...</p>" if has_more else "",
121193
body="</tr><tr>".join(
122194
[
123-
"\n".join(["<td>%s</td>" % (tup[name] if name in tup.dtype.names else "=BLOB=") for name in heading.names])
124-
for tup in tuples
195+
"\n".join(["<td>%s</td>" % get_html_display_value(tup, name, idx) for name in heading.names])
196+
for idx, tup in enumerate(tuples)
125197
]
126198
),
127199
count=(("<p>Total: %d</p>" % len(rel)) if config["display.show_tuple_count"] else ""),

0 commit comments

Comments
 (0)