docs / storage / sharded-transfer · API v1 · updated 2026-10-12
Sharded transfer of datasets
This page describes how Dataset-Grid stores, accepts and serves multi-terabyte datasets, and how to talk to the /storage/shards endpoint directly when the CLI is not an option (custom data loaders, mirrors in isolated networks, streaming into a training cluster).
Overview
A dataset revision is an immutable manifest: a list of shards, each described by its path, size, SHA-256 and an ordered list of block hashes. At the storage layer, shard bytes are never addressed by path — only by block hash. This gives three properties that matter for datasets of 1–50 TB:
- Deduplication. When a new revision only appends rows to some shards, only the changed blocks are re-uploaded.
- Resumability. A transfer is a set of independent block requests; a dropped connection costs at most one block.
- Verifiability. The client checks every block against its hash before writing, then the whole shard against the manifest.
Authentication
Every request passes a workspace token in the Authorization header. Tokens are created under Settings → Access tokens and are scoped: read tokens can only download, write tokens can also create uploads and commits. In enterprise workspaces, tokens are issued only to users who signed in via SSO (SAML 2.0 / OIDC).
export DG_ENDPOINT="https://<your-workspace-host>" # see Settings → API
export DG_TOKEN="dg_live_…" # do not commit this value
Storage model
Shards are split into fixed-size blocks of 8 MiB (8,388,608 bytes); the last block of a shard is usually shorter. A block is identified by the SHA-256 of its bytes in lowercase hex.
{
"dataset": "vault-labs/ru-web-corpus-2026",
"revision": "8c1e0a2f7d3b4e61",
"block_size": 8388608,
"shards": [
{
"path": "data/shard-00001-of-00420.parquet",
"size": 4001112064,
"sha256": "0f6c2d…e91a",
"blocks": ["5b9e04…71cd", "c013aa…0b42", "…"] // 477 hashes
}
]
}
Upload
- Split each shard into 8 MiB blocks and compute the SHA-256 of each block.
POST /api/v1/datasets/{dataset}/uploadswith the shard path, size and ordered list of hashes. The response carries anupload_idand the subset of hashes not yet on the server (missing).- For each missing hash,
PUT /storage/shards/{sha256}with the raw block bytes and anX-DG-Upload-Idheader. The server hashes the body and rejects it with400 block_digest_mismatchif the hash does not match the URL. Requests are independent and may run in parallel (up to 16 per token). POST /api/v1/datasets/{dataset}/uploads/{upload_id}/commit. The server verifies that all blocks are present and creates a new revision. An upload session expires after 24 hours of inactivity; uploaded blocks are kept for 7 days and reused by the next session.
A PUT for an already-existing block returns 200 OK without reading the body to the end, so blindly retrying a whole upload is cheap.
Download
- Fetch the manifest of a branch, tag or revision hash:
GET /api/v1/datasets/{dataset}/manifest?revision=main. - Pre-allocate the target shard to
sizebytes. - For each block i,
GET /storage/shards/{sha256}and write the body at offseti × block_size. Responses carryETag: "<sha256>"andCache-Control: immutableand supportRangeto resume a partially received block (206 Partial Content). - Verify each block against its hash before writing, and the whole shard against
sha256at the end.
Large responses may be served via a 307 Temporary Redirect to the nearest storage node with a signed URL valid for 15 minutes. The client must follow redirects and must not send the Authorization header to the redirect target.
Index and streaming without a full pull
For selective reads, every revision has an index at /grid/index/segment.arrow — an Arrow table that maps each dataset row to its shard, row-group offset and length. By loading only the index (tens of MiB), a training loader fetches the needed row groups with Range requests to /storage/shards without downloading a whole shard. Parquet metadata (schema, row-group statistics) lives in the file footer, so the schema of a 4 GB shard can be read with two Range requests — the last 8 bytes (footer length) and the footer itself:
FIRST=$(curl -s -H "Authorization: Bearer $DG_TOKEN" \
"$DG_ENDPOINT/api/v1/datasets/vault-labs/ru-web-corpus-2026/manifest" | jq -r '.shards[0].blocks[0]')
# last 8 bytes of the first block — the Parquet footer length (before the "PAR1" magic)
curl -s -L -r -8 -H "Authorization: Bearer $DG_TOKEN" \
"$DG_ENDPOINT/storage/shards/$FIRST" | od -An -t u4 --endian=little
Python example
The snippet below uses only requests and the standard library. It is a reference implementation of the protocol; in production use dataset-grid-cli, which has exponential-backoff retries, a progress bar and a local block cache.
import hashlib
import os
from concurrent.futures import ThreadPoolExecutor
import requests
ENDPOINT = os.environ["DG_ENDPOINT"]
BLOCK_SIZE = 8 * 1024 * 1024
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['DG_TOKEN']}"
def read_block(path, index):
with open(path, "rb") as f:
f.seek(index * BLOCK_SIZE)
return f.read(BLOCK_SIZE)
def block_digests(path):
digests = []
with open(path, "rb") as f:
while chunk := f.read(BLOCK_SIZE):
digests.append(hashlib.sha256(chunk).hexdigest())
return digests
def upload(path, dataset, target_path):
digests = block_digests(path)
r = session.post(f"{ENDPOINT}/api/v1/datasets/{dataset}/uploads", json={
"path": target_path,
"size": os.path.getsize(path),
"blocks": digests,
})
r.raise_for_status()
plan = r.json()
missing = set(plan["missing"])
# first index of each missing hash (identical blocks are uploaded once)
todo = {d: i for i, d in reversed(list(enumerate(digests))) if d in missing}
def put(item):
digest, index = item
resp = session.put(
f"{ENDPOINT}/storage/shards/{digest}",
data=read_block(path, index),
headers={"Content-Type": "application/octet-stream",
"X-DG-Upload-Id": plan["upload_id"]},
timeout=300,
)
resp.raise_for_status()
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(put, todo.items()))
commit = session.post(f"{ENDPOINT}/api/v1/datasets/{dataset}/uploads/{plan['upload_id']}/commit")
commit.raise_for_status()
return commit.json()["revision"]
def download(dataset, shard_path, dest, revision="main"):
r = session.get(f"{ENDPOINT}/api/v1/datasets/{dataset}/manifest", params={"revision": revision})
r.raise_for_status()
manifest = r.json()
entry = next(s for s in manifest["shards"] if s["path"] == shard_path)
size = manifest["block_size"]
with open(dest, "ab") as f: # allocate space, keeping what is already downloaded
f.truncate(entry["size"])
def fetch(item):
index, digest = item
with open(dest, "r+b") as f:
f.seek(index * size)
if hashlib.sha256(f.read(size)).hexdigest() == digest:
return # already present: resume
resp = session.get(f"{ENDPOINT}/storage/shards/{digest}", timeout=300)
resp.raise_for_status()
if hashlib.sha256(resp.content).hexdigest() != digest:
raise IOError(f"block {index}: digest mismatch")
with open(dest, "r+b") as f:
f.seek(index * size)
f.write(resp.content)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(fetch, enumerate(entry["blocks"])))
h = hashlib.sha256()
with open(dest, "rb") as f:
while chunk := f.read(BLOCK_SIZE):
h.update(chunk)
if h.hexdigest() != entry["sha256"]:
raise IOError(f"{shard_path}: shard digest mismatch")
if __name__ == "__main__":
download("vault-labs/ru-web-corpus-2026",
"data/shard-00001-of-00420.parquet",
"shard-00001-of-00420.parquet")
curl example
Useful for debugging and in environments without Python. $SHA is a single block hash; $UPLOAD_ID comes from the step 2 response in Upload.
# 1. Is this block on the server? (200 — yes, 404 — needs upload)
curl -I -H "Authorization: Bearer $DG_TOKEN" "$DG_ENDPOINT/storage/shards/$SHA"
# 2. Upload block #0 of the shard (bytes 0 … 8 MiB − 1)
dd if=data/shard-00001-of-00420.parquet bs=8M skip=0 count=1 status=none \
| curl --fail -X PUT --data-binary @- \
-H "Authorization: Bearer $DG_TOKEN" \
-H "Content-Type: application/octet-stream" \
-H "X-DG-Upload-Id: $UPLOAD_ID" \
"$DG_ENDPOINT/storage/shards/$SHA"
# 3. Download a block; -C - resumes an interrupted transfer via Range
curl --fail -L -C - -o "blocks/$SHA" \
-H "Authorization: Bearer $DG_TOKEN" \
"$DG_ENDPOINT/storage/shards/$SHA"
# 4. Verify
echo "$SHA blocks/$SHA" | sha256sum -c -
Limits
| Parameter | Value |
|---|---|
| Block size | 8 MiB (fixed; the last block may be shorter) |
| Maximum shard size | 5 TiB (655,360 blocks) |
| Parallel block requests | 16 per token, 64 per workspace (Business: 256) |
| Upload session lifetime | 24 h of inactivity |
| Signed URL lifetime | 15 min |
| API rate limit | 600 req/min per token (block transfers excluded) |
Errors
Errors are returned as JSON: {"error": "block_not_found", "message": "…", "request_id": "…"}. Quote the request_id when contacting support.
| Status | Code | Meaning |
|---|---|---|
400 | block_digest_mismatch | The body hash does not match the hash in the URL. Re-read the block from disk and retry. |
401 | invalid_token | Token is missing, expired or revoked. |
403 | insufficient_scope | A read token was used for a write, or the dataset is out of the token's scope. |
404 | block_not_found | The block is not stored (or not visible to this workspace). |
409 | upload_expired | The upload session expired. Start a new one — already-uploaded blocks will be reused. |
413 | block_too_large | The body is larger than 8 MiB. |
416 | range_not_satisfiable | The requested range is outside the block. |
429 | rate_limited | Too many parallel requests. Wait Retry-After seconds. |
507 | quota_exceeded | The workspace storage quota is reached. See pricing and add-ons. |
Something unclear or missing?
Contact support