Skip to content
Dataset-Grid
ru

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).

shell
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.

manifest.json (excerpt)GET /api/v1/datasets/{dataset}/manifest
{
  "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

  1. Split each shard into 8 MiB blocks and compute the SHA-256 of each block.
  2. POST /api/v1/datasets/{dataset}/uploads with the shard path, size and ordered list of hashes. The response carries an upload_id and the subset of hashes not yet on the server (missing).
  3. For each missing hash, PUT /storage/shards/{sha256} with the raw block bytes and an X-DG-Upload-Id header. The server hashes the body and rejects it with 400 block_digest_mismatch if the hash does not match the URL. Requests are independent and may run in parallel (up to 16 per token).
  4. 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

  1. Fetch the manifest of a branch, tag or revision hash: GET /api/v1/datasets/{dataset}/manifest?revision=main.
  2. Pre-allocate the target shard to size bytes.
  3. For each block i, GET /storage/shards/{sha256} and write the body at offset i × block_size. Responses carry ETag: "<sha256>" and Cache-Control: immutable and support Range to resume a partially received block (206 Partial Content).
  4. Verify each block against its hash before writing, and the whole shard against sha256 at 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:

bash
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.

dg_transfer.py
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.

bash
# 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

ParameterValue
Block size8 MiB (fixed; the last block may be shorter)
Maximum shard size5 TiB (655,360 blocks)
Parallel block requests16 per token, 64 per workspace (Business: 256)
Upload session lifetime24 h of inactivity
Signed URL lifetime15 min
API rate limit600 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.

StatusCodeMeaning
400block_digest_mismatchThe body hash does not match the hash in the URL. Re-read the block from disk and retry.
401invalid_tokenToken is missing, expired or revoked.
403insufficient_scopeA read token was used for a write, or the dataset is out of the token's scope.
404block_not_foundThe block is not stored (or not visible to this workspace).
409upload_expiredThe upload session expired. Start a new one — already-uploaded blocks will be reused.
413block_too_largeThe body is larger than 8 MiB.
416range_not_satisfiableThe requested range is outside the block.
429rate_limitedToo many parallel requests. Wait Retry-After seconds.
507quota_exceededThe workspace storage quota is reached. See pricing and add-ons.

Something unclear or missing?

Contact support