INTEGRITY Dokumentace

Rozdělení streamu do více tabulek Iceberg

V tomto příkladu budete odebírat veřejný Bluesky Jetstream ↗ firehose, živý WebSocket stream všech příspěvků, lajků, repostů, sledování a blokování v síti, a uložte jej do R2 Data Catalog jako dotazovatelné tabulky Apache Iceberg.

Naučíte se základní vzor služby Pipelines: všechny události pošlete do jednoho streamu a poté pomocí jediné pipeline s několika SQL příkazy tento stream rozdělíte ("fan out") do několika cílových tabulek, jedné pro každý typ události, aniž byste pro každou z nich museli spouštět samostatnou pipeline.

flowchart TD
    A[Bluesky Jetstream WebSocket] --> B[Durable Object]
    B -->|send| C[bsky_events_stream]
    C --> D[bsky_pipeline]
    D --> E[bsky_post]
    D --> F[bsky_like]
    D --> G[bsky_repost]
    D --> H[bsky_follow]
    D --> I[bsky_block]

Předpoklady

  1. Zaregistrujte si účet Cloudflare ↗.
  2. Nainstalujte Node.js ↗.

Správce verzí Node.js

Použijte správce verzí Node, jako je Volta ↗ nebo nvm ↗ abyste se vyhnuli problémům s oprávněními a mohli měnit verze Node.js. Wrangler, o kterém se dozvíte dále v této příručce, vyžaduje verzi Node 16.17.0 nebo novější.

Budete také potřebovat Token R2 API hodnotou Admin Read & Write oprávnění, což zahrnuje přístup k R2 Data Catalog a R2 SQL. Tento token předáte každému sinku. Účet Bluesky ani API klíč nejsou potřeba, protože Jetstream je veřejný a nevyžaduje ověření.

1. Vytvořte nový projekt Worker

Nový projekt Worker vytvoříte spuštěním následujícího příkazu:

npm create cloudflare@latest -- bluesky-pipeline

Při nastavení vyberte následující možnosti:

Přejděte do adresáře svého nového projektu:

cd bluesky-pipeline

pipelines příkazy použité dále vyžadují Wrangler v4 nebo novější. Pokud byl váš projekt vytvořen se starší verzí, aktualizujte ji nyní:

npm i -D wrangler@latest

2. Definujte schéma streamu

Stream má jediné schéma. Protože stejným streamem prochází všechny typy událostí, je schéma sjednocením polí požadovaných napříč všemi typy. Každý směrovací příkaz pak vybírá jen sloupce relevantní pro svůj cíl.

Vytvořte schema.json soubor v kořenovém adresáři vašeho projektu:

{
	"fields": [
		{ "name": "event_id", "type": "string", "required": true },
		{ "name": "event_type", "type": "string", "required": true },
		{ "name": "did", "type": "string", "required": false },
		{ "name": "operation", "type": "string", "required": false },
		{ "name": "event_time", "type": "timestamp", "required": false },
		{ "name": "created_at", "type": "string", "required": false },
		{ "name": "text", "type": "string", "required": false },
		{ "name": "langs", "type": "string", "required": false },
		{ "name": "subject_uri", "type": "string", "required": false },
		{ "name": "subject_did", "type": "string", "required": false }
	]
}

3. Vytvořte R2 bucket a povolte R2 Data Catalog

Vaše sinky zapisují do tabulek Iceberg v R2 Data Catalog, proto potřebujete bucket s povoleným katalogem.

Vytvořte bucket R2 s názvem bluesky-pipeline:

npx wrangler r2 bucket create bluesky-pipeline

Povolte R2 Data Catalog na bucketu:

npx wrangler r2 bucket catalog enable bluesky-pipeline

Při spuštění tohoto příkazu si poznamenejte Název warehouse. Budete ho potřebovat k dotazování vašich dat pomocí R2 SQL.

4. Vytvořte stream, sinky a pipeline

Nejprve vytvořte stream ze souboru schématu:

npx wrangler pipelines streams create bsky_events_stream --schema-file schema.json

Všimněte si stream ID ve výstupu. V dalším kroku ji použijete ke konfiguraci Worker bindingu.

Dále vytvořte jeden sink na cílovou tabulku. Každý sink zapisuje do vlastní tabulky Iceberg v R2 Data Catalog. Nahraďte YOUR_CATALOG_TOKEN s vaším API tokenem R2.

for t in post like repost follow block; do
  npx wrangler pipelines sinks create bsky_${t}_sink \
    --type r2-data-catalog \
    --bucket bluesky-pipeline \
    --namespace bluesky \
    --table bsky_${t} \
    --catalog-token YOUR_CATALOG_TOKEN \
    --roll-interval 60
done

Nyní vytvořte jeden pipeline, jehož SQL obsahuje více INSERT příkazy, jeden pro každou trasu (route). Každý příkaz filtruje stream podle event_type a promítá pouze sloupce, které jsou pro danou tabulku relevantní.

Vytvořte fanout.sql soubor:

INSERT INTO bsky_post_sink
  SELECT event_id, did, operation, event_time, created_at, text, langs
  FROM bsky_events_stream WHERE event_type = 'post';

INSERT INTO bsky_like_sink
  SELECT event_id, did, operation, event_time, created_at, subject_uri
  FROM bsky_events_stream WHERE event_type = 'like';

INSERT INTO bsky_repost_sink
  SELECT event_id, did, operation, event_time, created_at, subject_uri
  FROM bsky_events_stream WHERE event_type = 'repost';

INSERT INTO bsky_follow_sink
  SELECT event_id, did, operation, event_time, created_at, subject_did
  FROM bsky_events_stream WHERE event_type = 'follow';

INSERT INTO bsky_block_sink
  SELECT event_id, did, operation, event_time, created_at, subject_did
  FROM bsky_events_stream WHERE event_type = 'block';

Vytvořte pipeline ze souboru:

npx wrangler pipelines create bsky_pipeline --sql-file fanout.sql

Jeden pipeline zapisuje do pěti tabulek. Pokud chcete později přidat nový typ události, přidejte jeden sink a jeden INSERT příkaz. SQL pipeline nelze po vytvoření upravovat, takže ke změně je nutné pipeline smazat a znovu vytvořit. Další informace najdete v Směrování jednoho streamu do více tabulek.

5. Připojte stream ke svému Workeru

Přidejte stream binding, Durable Object pro uchování WebSocket připojení a Cron Trigger aby konzument zůstal aktivní. Nahraďte <STREAM_ID> s ID streamu z kroku 4.

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "bluesky-pipeline",
  "main": "src/index.ts",
  // Set this to today's date
  "compatibility_date": "2026-08-28",
  "pipelines": [
    {
      "binding": "BSKY_STREAM",
      "stream": "<STREAM_ID>"
    }
  ],
  "durable_objects": {
    "bindings": [
      {
        "name": "JETSTREAM",
        "class_name": "JetstreamConsumer"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": [
        "JetstreamConsumer"
      ]
    }
  ],
  "triggers": {
    "crons": [
      "*/2 * * * *"
    ]
  }
}
name = "bluesky-pipeline"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-28"

[[pipelines]]
binding = "BSKY_STREAM"
stream = "<STREAM_ID>"

[[durable_objects.bindings]]
name = "JETSTREAM"
class_name = "JetstreamConsumer"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["JetstreamConsumer"]

[triggers]
crons = ["*/2 * * * *"]

6. Zpracujte firehose v Durable Object

A Durable Object je vhodným místem pro dlouhodobě otevřený WebSocket. Zůstává rezidentní, dokud je socket otevřený, a alarm ho znovu připojí, pokud spojení vypadne. Ukládejte příchozí události do vyrovnávací paměti a send() je do streamu v dávkách, abyste zůstali pod limitem 5 MB na požadavek. Uchovávejte Jetstream time_us kurzor, aby opětovné připojení pokračovalo bez výpadků.

Nahraďte obsah src/index.ts následujícím:

src/index.js
import { DurableObject } from "cloudflare:workers";

// Jetstream collection -> our short event_type. Only these are kept.
const COLLECTION_TO_TYPE = {
	"app.bsky.feed.post": "post",
	"app.bsky.feed.like": "like",
	"app.bsky.feed.repost": "repost",
	"app.bsky.graph.follow": "follow",
	"app.bsky.graph.block": "block",
};
const WANTED = Object.keys(COLLECTION_TO_TYPE);
const JETSTREAM_URL = "https://jetstream2.us-east.bsky.network/subscribe";
const FLUSH_MAX = 500; // rows per send()
const FLUSH_MS = 1000; // flush at least once per second
const RECONNECT_MS = 15000;

// Flatten one Jetstream message into a unified stream row, or null to skip.
function toRow(ev) {
	if (ev?.kind !== "commit" || !ev.commit) return null;
	const c = ev.commit;
	const event_type = COLLECTION_TO_TYPE[c.collection];
	if (!event_type) return null;
	const r = c.record ?? {};
	const subject = r.subject;
	return {
		event_id: `${ev.did}/${c.collection}/${c.rkey}`,
		event_type,
		did: ev.did ?? null,
		operation: c.operation ?? null,
		event_time:
			typeof ev.time_us === "number"
				? new Date(ev.time_us / 1000).toISOString()
				: null,
		created_at: typeof r.createdAt === "string" ? r.createdAt : null,
		text: event_type === "post" && typeof r.text === "string" ? r.text : null,
		langs:
			event_type === "post" && Array.isArray(r.langs)
				? r.langs.join(",")
				: null,
		subject_uri: typeof subject === "object" ? (subject?.uri ?? null) : null,
		subject_did: typeof subject === "string" ? subject : null,
	};
}

export class JetstreamConsumer extends DurableObject {
	ws = null;
	buf = [];
	lastFlush = 0;
	cursor = null;
	flushing = false;

	// Arm the reconnect watchdog first, then connect (idempotent).
	async start() {
		await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
		await this.ensureConnected();
		return { connected: this.ws !== null };
	}

	async ensureConnected() {
		if (this.ws) return;
		this.cursor ??= (await this.ctx.storage.get("cursor")) ?? null;

		const params = new URLSearchParams();
		for (const c of WANTED) params.append("wantedCollections", c);
		if (this.cursor) params.set("cursor", String(this.cursor));

		const resp = await fetch(`${JETSTREAM_URL}?${params}`, {
			headers: { Upgrade: "websocket" },
		});
		const ws = resp.webSocket;
		if (!ws) throw new Error(`Jetstream handshake failed: ${resp.status}`);
		ws.accept();
		this.ws = ws;

		ws.addEventListener("message", (e) => this.onMessage(e));
		ws.addEventListener("close", () => (this.ws = null));
		ws.addEventListener("error", () => (this.ws = null));
	}

	onMessage(e) {
		let ev;
		try {
			ev = JSON.parse(e.data);
		} catch {
			return;
		}
		if (typeof ev.time_us === "number") this.cursor = ev.time_us;
		const row = toRow(ev);
		if (row) this.buf.push(row);
		if (
			this.buf.length >= FLUSH_MAX ||
			Date.now() - this.lastFlush >= FLUSH_MS
		) {
			void this.flush();
		}
	}

	// Serialize sends: flush one batch at a time, advancing the cursor on success.
	async flush() {
		if (this.flushing) return;
		this.flushing = true;
		try {
			while (this.buf.length > 0) {
				this.lastFlush = Date.now();
				const batch = this.buf.splice(0, this.buf.length);
				const batchCursor = this.cursor;
				try {
					await this.env.BSKY_STREAM.send(batch);
					await this.ctx.storage.put("cursor", batchCursor);
				} catch (err) {
					this.buf.unshift(...batch);
					console.error("send failed, will retry", err);
					return;
				}
			}
		} finally {
			this.flushing = false;
		}
	}

	// Watchdog: reconnect if dropped, flush stragglers, always reschedule.
	async alarm() {
		try {
			await this.ensureConnected();
			await this.flush();
		} catch (err) {
			console.error("alarm error", err);
		} finally {
			await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
		}
	}
}

export default {
	async fetch(_req, env) {
		const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
		return Response.json(await stub.start());
	},
	async scheduled(_event, env) {
		const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
		await stub.start();
	},
};
src/index.ts
import { DurableObject } from "cloudflare:workers";
import type { Pipeline } from "cloudflare:pipelines";

interface Env {
	BSKY_STREAM: Pipeline;
	JETSTREAM: DurableObjectNamespace<JetstreamConsumer>;
}

// Jetstream collection -> our short event_type. Only these are kept.
const COLLECTION_TO_TYPE: Record<string, string> = {
	"app.bsky.feed.post": "post",
	"app.bsky.feed.like": "like",
	"app.bsky.feed.repost": "repost",
	"app.bsky.graph.follow": "follow",
	"app.bsky.graph.block": "block",
};
const WANTED = Object.keys(COLLECTION_TO_TYPE);
const JETSTREAM_URL = "https://jetstream2.us-east.bsky.network/subscribe";
const FLUSH_MAX = 500; // rows per send()
const FLUSH_MS = 1000; // flush at least once per second
const RECONNECT_MS = 15000;

// Flatten one Jetstream message into a unified stream row, or null to skip.
function toRow(ev: any) {
	if (ev?.kind !== "commit" || !ev.commit) return null;
	const c = ev.commit;
	const event_type = COLLECTION_TO_TYPE[c.collection];
	if (!event_type) return null;
	const r = c.record ?? {};
	const subject = r.subject;
	return {
		event_id: `${ev.did}/${c.collection}/${c.rkey}`,
		event_type,
		did: ev.did ?? null,
		operation: c.operation ?? null,
		event_time:
			typeof ev.time_us === "number"
				? new Date(ev.time_us / 1000).toISOString()
				: null,
		created_at: typeof r.createdAt === "string" ? r.createdAt : null,
		text: event_type === "post" && typeof r.text === "string" ? r.text : null,
		langs:
			event_type === "post" && Array.isArray(r.langs)
				? r.langs.join(",")
				: null,
		subject_uri: typeof subject === "object" ? (subject?.uri ?? null) : null,
		subject_did: typeof subject === "string" ? subject : null,
	};
}

export class JetstreamConsumer extends DurableObject<Env> {
	private ws: WebSocket | null = null;
	private buf: Record<string, unknown>[] = [];
	private lastFlush = 0;
	private cursor: number | null = null;
	private flushing = false;

	// Arm the reconnect watchdog first, then connect (idempotent).
	async start() {
		await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
		await this.ensureConnected();
		return { connected: this.ws !== null };
	}

	private async ensureConnected() {
		if (this.ws) return;
		this.cursor ??= (await this.ctx.storage.get<number>("cursor")) ?? null;

		const params = new URLSearchParams();
		for (const c of WANTED) params.append("wantedCollections", c);
		if (this.cursor) params.set("cursor", String(this.cursor));

		const resp = await fetch(`${JETSTREAM_URL}?${params}`, {
			headers: { Upgrade: "websocket" },
		});
		const ws = resp.webSocket;
		if (!ws) throw new Error(`Jetstream handshake failed: ${resp.status}`);
		ws.accept();
		this.ws = ws;

		ws.addEventListener("message", (e) => this.onMessage(e));
		ws.addEventListener("close", () => (this.ws = null));
		ws.addEventListener("error", () => (this.ws = null));
	}

	private onMessage(e: MessageEvent) {
		let ev: any;
		try {
			ev = JSON.parse(e.data as string);
		} catch {
			return;
		}
		if (typeof ev.time_us === "number") this.cursor = ev.time_us;
		const row = toRow(ev);
		if (row) this.buf.push(row);
		if (
			this.buf.length >= FLUSH_MAX ||
			Date.now() - this.lastFlush >= FLUSH_MS
		) {
			void this.flush();
		}
	}

	// Serialize sends: flush one batch at a time, advancing the cursor on success.
	private async flush() {
		if (this.flushing) return;
		this.flushing = true;
		try {
			while (this.buf.length > 0) {
				this.lastFlush = Date.now();
				const batch = this.buf.splice(0, this.buf.length);
				const batchCursor = this.cursor;
				try {
					await this.env.BSKY_STREAM.send(batch);
					await this.ctx.storage.put("cursor", batchCursor);
				} catch (err) {
					this.buf.unshift(...batch);
					console.error("send failed, will retry", err);
					return;
				}
			}
		} finally {
			this.flushing = false;
		}
	}

	// Watchdog: reconnect if dropped, flush stragglers, always reschedule.
	async alarm() {
		try {
			await this.ensureConnected();
			await this.flush();
		} catch (err) {
			console.error("alarm error", err);
		} finally {
			await this.ctx.storage.setAlarm(Date.now() + RECONNECT_MS);
		}
	}
}

export default {
	async fetch(_req, env): Promise<Response> {
		const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
		return Response.json(await stub.start());
	},
	async scheduled(_event, env): Promise<void> {
		const stub = env.JETSTREAM.get(env.JETSTREAM.idFromName("singleton"));
		await stub.start();
	},
} satisfies ExportedHandler<Env>;

Vygenerujte typy pro vaše bindings:

npx wrangler types

7. Nasaďte a spusťte consumer

Nasaďte Worker:

npx wrangler deploy

Otevřete Worker URL jednou, čímž spustíte firehose. V chodu ho pak udržuje cron trigger:

curl https://bluesky-pipeline.YOUR_SUBDOMAIN.workers.dev

Příkaz vrátí:

{ "connected": true }

Sledujte logy příkazem tail a pozorujte, jak vše funguje:

npx wrangler tail

8. Dotazujte tabulky pomocí R2 SQL

První data se objeví několik minut po příchodu prvních událostí, zatímco se pipeline teprve rozjíždí.

Nastavte svůj token R2 SQL a poté dotazujte jednotlivé tabulky. Nahraďte YOUR_WAREHOUSE_NAME s názvem warehouse, který jste si poznamenali v kroku 3.

export WRANGLER_R2_SQL_AUTH_TOKEN=YOUR_CATALOG_TOKEN

npx wrangler r2 sql query "YOUR_WAREHOUSE_NAME" \
  "SELECT COUNT(*) FROM bluesky.bsky_like"

npx wrangler r2 sql query "YOUR_WAREHOUSE_NAME" \
  "SELECT text, langs FROM bluesky.bsky_post WHERE langs LIKE '%en%' LIMIT 10"

Každá tabulka obsahuje pouze svůj typ události, promítnutý do příslušných sloupců. Veškeré směrování zajistil jediný pipeline.

Závěr

Pomocí Durable Object jste zpracovali vysokorychlostní veřejný WebSocket firehose, přijali jej do jednoho streamu Pipelines a pomocí jediné pipeline s několika SQL příkazy jste tento stream rozdělili do pěti tabulek Iceberg podle typu události.

Tento vzor jeden stream pro více tabulek lze zobecnit na jakýkoli zdroj událostí se štítky: clickstreamy (podle event_type), logy (podle service nebo status), nebo IoT telemetrii (podle device_class). Chcete-li ji rozšířit, přidejte sink a odpovídající INSERT ... WHERE příkaz.

Chcete-li se dozvědět více o SQL použitém zde, přečtěte si Příkazy SELECT a Správa pipeline.