← Cloudflare Pipelines / pipelines / examples
Распределение stream по нескольким таблицам Iceberg
В этом примере вы будете получать данные из общедоступного Bluesky Jetstream ↗ firehose, живой WebSocket-поток всех публикаций, лайков, репостов, подписок и блокировок в сети, и сохранить его в R2 Data Catalog в виде таблиц Apache Iceberg, доступных для запросов.
Вы изучите базовый паттерн работы с Pipelines: все события отправляются в один поток, а затем один конвейер с несколькими SQL-операторами распределяет («fan out») этот поток по нескольким целевым таблицам, по одной на тип события, без запуска отдельного конвейера для каждой из них.
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]
Предварительные требования
- Зарегистрируйтесь для получения Аккаунт Cloudflare ↗.
- Установка
Node.js↗.
менеджер версий Node.js
Используйте менеджер версий Node, например Volta ↗ или nvm ↗ чтобы избежать проблем с правами доступа и переключать версии Node.js. Wrangler, о котором пойдёт речь далее в этом руководстве, требует версию Node 16.17.0 или более поздней версии.
Вам также понадобится R2 API-токен с Admin Read & Write разрешения, включающие доступ к R2 Data Catalog и R2 SQL. Этот токен вы передадите каждому sink. Учётная запись Bluesky или API-ключ не требуются, так как Jetstream общедоступен и не требует аутентификации.
1. Создайте новый проект Worker
Создайте новый проект Worker с помощью следующей команды:
npm create cloudflare@latest -- bluesky-pipelineДля настройки выберите следующие параметры:
- Для С чего вы хотите начать?, выберите
Hello World example. - Для Какой шаблон вы хотите использовать?, выберите
Worker only. - Для Какой язык вы хотите использовать?, выберите
TypeScript. - Для Хотите использовать git для контроля версий?, выберите
Yes. - Для Хотите развернуть приложение?, выберите
No(мы внесём некоторые изменения перед развёртыванием).
Перейдите в каталог нового проекта:
cd bluesky-pipeline pipelines команды, используемые далее, требуют Wrangler v4 или более поздней версии. Если ваш проект был создан с использованием более старой версии, обновите его сейчас:
npm i -D wrangler@latest2. Определение схемы stream
У потока есть единая схема. Поскольку события всех типов проходят через один и тот же поток, схема представляет собой объединение полей, необходимых для всех типов. Каждая инструкция маршрутизации впоследствии выбирает только те столбцы, которые относятся к её назначению.
Создайте schema.json файл в корне вашего проекта:
{
"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. Создание R2 bucket и включение R2 Data Catalog
Ваши приёмники записывают данные в таблицы Iceberg в R2 Data Catalog, поэтому вам понадобится bucket с включённым R2 Data Catalog.
Создайте R2 bucket с именем bluesky-pipeline:
npx wrangler r2 bucket create bluesky-pipelineВключите R2 Data Catalog для bucket:
npx wrangler r2 bucket catalog enable bluesky-pipelineПри выполнении этой команды запишите Имя хранилища. Он понадобится вам для запросов к данным с помощью R2 SQL.
4. Создание stream, sinks и pipeline
Сначала создайте stream из файла схемы:
npx wrangler pipelines streams create bsky_events_stream --schema-file schema.jsonОбратите внимание на stream ID в выводе. Оно понадобится вам для настройки привязки Worker на следующем шаге.
Далее создайте один sink на каждую целевую таблицу. Каждый sink записывает данные в отдельную таблицу Iceberg в R2 Data Catalog. Замените YOUR_CATALOG_TOKEN с вашим API-токеном 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Теперь создайте один конвейер, SQL которого содержит несколько INSERT операторов, по одному на маршрут. Каждый оператор фильтрует stream по event_type и проецирует только те столбцы, которые важны для этой таблицы.
Создайте fanout.sql файле:
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';Создайте pipeline из файла:
npx wrangler pipelines create bsky_pipeline --sql-file fanout.sqlОдин конвейер записывает данные в пять таблиц. Чтобы позже добавить новый тип события, добавьте один приёмник и один INSERT оператор. SQL-запрос pipeline нельзя изменить после создания, поэтому для внесения изменений pipeline нужно удалить и создать заново. Подробнее см. в Направление одного потока в несколько таблиц.
5. Привязка stream к Worker
Добавьте stream binding, Durable Object для хранения WebSocket подключения и Cron Trigger чтобы поддерживать работу потребителя. Замените <STREAM_ID> с ID stream из шага 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. Потребление firehose в Durable Object
A Durable Object хорошо подходит для долгоживущего WebSocket-соединения. Он остаётся в памяти, пока сокет открыт, а оповещение переподключает его при обрыве соединения. Буферизируйте входящие события и send() их в stream пакетами, чтобы оставаться в пределах лимита 5 МБ на запрос. Сохраняйте Jetstream time_us курсор, чтобы повторное подключение продолжалось без пропусков.
Замените содержимое src/index.ts на следующее:
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();
},
};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>;Сгенерируйте типы для своих привязок:
npx wrangler types7. Развертывание и запуск consumer
Разверните Worker:
npx wrangler deployОткройте URL-адрес Worker один раз, чтобы запустить непрерывный поток данных. Cron Trigger будет поддерживать его работу:
curl https://bluesky-pipeline.YOUR_SUBDOMAIN.workers.devКоманда возвращает:
{ "connected": true }Отслеживайте логи в реальном времени, чтобы увидеть, как это работает:
npx wrangler tail8. Запрос таблиц с помощью R2 SQL
Первые данные появляются через несколько минут после поступления первых событий, пока конвейер выходит на рабочий режим.
Настройте токен R2 SQL, а затем выполните запрос к каждой таблице. Замените YOUR_WAREHOUSE_NAME с именем warehouse, которое вы указали на шаге 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"Каждая таблица содержит только свой тип события, спроецированный на нужные столбцы. Всю маршрутизацию выполнил один pipeline.
Заключение
Вы подключились к высоконагруженному публичному WebSocket firehose с помощью Durable Object, направили данные в один поток Pipelines и с помощью одного конвейера с несколькими SQL-операторами распределили этот поток по пяти таблицам Iceberg в зависимости от типа события.
Этот паттерн «один поток на много таблиц» применим к любому источнику размеченных событий: например, к потокам кликов (по event_type), логи (по service или status), или IoT телеметрию (по device_class). Чтобы расширить его, добавьте sink и соответствующий INSERT ... WHERE оператор.
Дополнительные сведения об использованном здесь SQL см. в Операторы SELECT и Управление конвейерами.