Skip to main content

Blob Transfer

Changesets carry structured rows. Binary attachments — photos, audio, documents — are too large and too bursty to push through the CRDT changeset path, so Ara handles them with a separate content-addressed blob store and a dedicated byte-transfer channel.

The split is deliberate:

  • Metadata (ara_blobs) syncs as a normal CRDT relation — every node converges on the full catalogue of which blobs exist.
  • Bytes transfer out-of-band, resumably, and only to nodes whose policy asks for them.

Content addressing

A blob's id is the SHA-256 of its contents. That makes blobs immutable and de-duplicated: the same file ingested on two nodes produces the same id and is stored once. You reference the id as a foreign key from your own tables.

id, err := node.IngestBlob(ctx, "/tmp/photo.jpg", "image/jpeg")
// id == "9f86d081884c7d65..." (hex SHA-256)
node.Exec(ctx, "INSERT INTO clues (id, photo_id) VALUES (?, ?)", clueID, id)
node.Sync(ctx)

Lifecycle

  1. IngestIngestBlob copies the file into the store, computes its id, writes a row into ara_blobs (id, mime type, size, owner), and — for image MIME types — generates a ≤ 2 KB thumbnail.
  2. Announce + metadata — the owner broadcasts an Announce ("I have id") over every transport, and the ara_blobs row (a CRR) converges to every peer through the normal changeset path. Both happen on ingest.
  3. Byte fetch — a peer that wants the bytes reacts to the announce by the transport it arrived on: UDP pulls the full blob from the owner over the resumable TCP sidecar; MQTT requests the full blob inline; LoRa requests the thumbnail inline. Bytes always come from the recorded owner. The periodic CRDT sync cycle is a catch-up fallback for announces missed while a node was offline.
  4. Availability — once the bytes land, BlobPath(id) returns a local filesystem path instead of "" (or the thumbnail is available for preview).

API

Configure the store

Call once, before the node starts producing or receiving blobs.

node.SetBlobStore("/data/blobs", ara.BlobPolicy{
Mode: ara.BlobSyncFull,
MaxBytes: 500 << 20, // 500 MB total cap; 0 = unlimited
MaxBlobSize: 10 << 20, // skip blobs larger than 10 MB; 0 = unlimited
})
node.setBlobStore(
dir = filesDir.absolutePath + "/blobs",
policy = BlobPolicy(
mode = BlobSyncMode.Full, // None · ThumbOnly · Full
maxBytes = 500L shl 20,
maxBlobSize = 10L shl 20,
),
)

Ingest a file

id, err := node.IngestBlob(ctx, path, mimeType) // mimeType "" → application/octet-stream
val id = node.ingestBlob(path = "/sdcard/photo.jpg", mimeType = "image/jpeg")

Resolve to a local path

path := node.BlobPath(id) // "" if not yet present locally
if path == "" {
// bytes haven't arrived yet — show a placeholder / pending state
}
val path = node.blobPath(id) // "" if not yet present

Blob policy

The policy decides whether a node automatically pulls blob bytes after it sees the metadata.

type BlobPolicy struct {
Mode BlobSyncMode
MaxBytes int64 // total store cap in bytes; 0 = unlimited
MaxBlobSize int64 // skip individual blobs larger than this; 0 = unlimited
}

const (
BlobSyncNone BlobSyncMode = 0 // metadata only; never pull bytes (default)
BlobSyncThumbOnly BlobSyncMode = 1 // pull thumbnails only (≤ 2 KB)
BlobSyncFull BlobSyncMode = 2 // pull full blobs when the transport allows
)
ModeBehaviourTypical use
None (0)Stores only what it ingests; never fetches from peersLoRa-only field node conserving airtime
ThumbOnly (1)Pulls thumbnails (≤ 2 KB); defers full bytesField node that needs previews
Full (2)Pulls full bytes from any peer that has themICP / MQTT-connected node

MaxBytes caps total store size; MaxBlobSize skips individual blobs above a threshold. A node that skips a blob still keeps the metadata, so it can fetch the bytes later (e.g. once on a fatter transport) by raising its policy.

Transfer over each transport

Byte transfer adapts to the link — the metadata path is identical everywhere, only the delivery of bytes differs.

TransportByte channelNotes
UDP LANTCP sidecar on port + 1Resumable streaming; full resolution
MQTTbrokerFull resolution, effectively immediate
Meshtastic / LoRachunked over the channelThumbnail only (≤ 2 KB, ~20 min); full res deferred to MQTT/WiFi

UDP TCP sidecar

When a blob store is configured, the UDP transport opens a TCP listener on port + 1 (default 7947) alongside its UDP socket. CRDT changesets continue over UDP; this sidecar carries blob bytes only. After a changeset merge introduces new ara_blobs rows the node wants, it dials the owner's sidecar and streams them, resuming from any partial download.

The wire protocol uses a 53-byte binary header:

BytesField
0–3Magic 0x41524142 ("ARAB")
4Op: 0x01 send · 0x02 fetch-request · 0x03 fetch-response
5–36Blob id (32 bytes, raw SHA-256)
37–44Offset (uint64 big-endian) — resume position
45–52Size (uint64 big-endian) — byte count following the header

Tiered delivery for LoRa

On a LoRa-constrained mesh, full photos are impractical (a 2 KB thumbnail alone is ~11 packets / ~20 min at the sustained budget). The pattern is:

  • Field nodes: BlobSyncThumbOnly — pull the thumbnail over LoRa for a usable preview.
  • ICP / connected nodes: BlobSyncFull — pull full resolution over MQTT or WiFi.

Because metadata reaches everyone, a node that only has the thumbnail can upgrade to full resolution automatically the moment it reaches a transport that allows it. See LoRa transport → Attachment delivery.

Storage tables

TableSynced?Purpose
ara_blobs✅ via CRDTBlob metadata — id (SHA-256), mime type, size, origin node
ara_blob_local_state❌ local onlyPer-device availability flags — have_full, have_thumb

The SDK manages both tables; you don't create or migrate them. ara_blobs is a CRR so all nodes converge on the full set of blob metadata. ara_blob_local_state is never replicated — each device tracks its own availability so a node can decide to fetch a blob even if the origin already marked it locally present.

Reference ara_blobs.id as a foreign key from your own tables:

CREATE TABLE clues (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
attachment_id TEXT, -- FK → ara_blobs.id, nullable
created_at INTEGER NOT NULL
) STRICT;