Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Need to deploy your Worker to Cloudflare? Python Workers are in open beta and ha
- [**`langchain/`**](langchain) — demonstrates how to use the [LangChain](https://pypi.org/project/langchain/) package with Python Workers. Currently broken.
- [**`assets/`**](assets) — An example with an assets binding.
- [**`durable-objects/`**](durable-objects) — An example with storing state in a [Durable Object](https://developers.cloudflare.com/durable-objects/).
- [**`hyperdrive-batched-counter/`**](hyperdrive-batched-counter) — batches high-frequency counters with Durable Objects and PostgreSQL through Hyperdrive.
- [**`cron/`**](cron) — shows a simple [cron job](https://developers.cloudflare.com/workers/configuration/cron-triggers/).
- [**`workers-ai/`**](workers-ai) makes a call [Workers AI](https://developers.cloudflare.com/workers-ai/) to run inference on Cloudflare's Global Network.
- [**`vectorize-rag/`**](vectorize-rag) — a RAG example using remote Workers AI and [Vectorize](https://developers.cloudflare.com/vectorize/) bindings.
Expand Down
3 changes: 3 additions & 0 deletions hyperdrive-batched-counter/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM postgres:16

COPY schema.sql /docker-entrypoint-initdb.d/001-schema.sql
44 changes: 44 additions & 0 deletions hyperdrive-batched-counter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Batched Counter with Durable Objects and Hyperdrive

This example shows how to use Durable Objects and Hyperdrive to build a unique counter
for multiple users.

1. It assigns one Python Durable Object to each room.
2. Reactions that increase the count are kept in an in-memory dictionary for fast aggregation first,
and persisted to Durable Object storage until an alarm flushes them to PostgreSQL through Hyperdrive every five seconds.

## Prerequisites

- [Docker Compose](https://docs.docker.com/compose/) - for local PostgreSQL
- [uv](https://docs.astral.sh/uv/getting-started/installation/)

## Run locally

Start PostgreSQL, install the Python dependencies, then start the Worker:

```sh
docker compose up --build -d --wait
uv run pywrangler dev
```

Open `http://localhost:8787` to join a room, send reactions, and watch each
five-second batch move from the Durable Object to PostgreSQL.

```sh
curl -X POST http://localhost:8787/rooms/concert/reactions/heart
curl -X POST http://localhost:8787/rooms/concert/reactions/laugh
curl http://localhost:8787/rooms/concert/stats
```

The stats response separates accepted `totals`, PostgreSQL `persisted` totals, and the
currently buffered `pending` counts.

## Deploy

Apply `schema.sql` to the production PostgreSQL database first. Then create a Hyperdrive
configuration for that database, replace the placeholder `id` in `wrangler.jsonc` with
its Hyperdrive ID, and run:

```sh
uv run pywrangler deploy
```
23 changes: 23 additions & 0 deletions hyperdrive-batched-counter/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
services:
postgres:
build:
context: .
dockerfile: Dockerfile
environment:
POSTGRES_USER: reactions
POSTGRES_PASSWORD: reactions
POSTGRES_DB: reactions
POSTGRES_HOST_AUTH_METHOD: md5
POSTGRES_INITDB_ARGS: --auth-host=md5
ports:
- "5432:5432"
volumes:
- reaction-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reactions -d reactions"]
interval: 5s
timeout: 5s
retries: 5

volumes:
reaction-data:
Empty file.
13 changes: 13 additions & 0 deletions hyperdrive-batched-counter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "batched-counter",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "uv run pywrangler deploy",
"dev": "uv run pywrangler dev",
"start": "uv run pywrangler dev"
},
"devDependencies": {
"wrangler": "^4.127.1"
}
}
204 changes: 204 additions & 0 deletions hyperdrive-batched-counter/public/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
const reactions = ["heart", "laugh", "fire"];
const roomPattern = /^[A-Za-z0-9_-]{1,64}$/;
const pollIntervalMs = 1000;

const state = {
room: null,
stats: emptyStats(),
pollId: null,
statsController: null,
polling: false,
generation: 0,
};

const roomForm = document.querySelector("#room-form");
const roomInput = document.querySelector("#room-input");
const activeRoom = document.querySelector("#active-room");
const connection = document.querySelector("#connection");
const connectionText = document.querySelector("#connection-text");
const lastSynced = document.querySelector("#last-synced");
const totalAccepted = document.querySelector("#total-accepted");
const statsBody = document.querySelector("#stats-body");
const batchCycle = document.querySelector("#batch-cycle");
const batchStatus = document.querySelector("#batch-status");
const appStatus = document.querySelector("#app-status");
const reactionButtons = [...document.querySelectorAll(".reaction-button")];

function emptyCounts() {
return Object.fromEntries(reactions.map((reaction) => [reaction, 0]));
}

function emptyStats() {
return { totals: emptyCounts(), persisted: emptyCounts(), pending: emptyCounts() };
}

function count(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, Math.trunc(number)) : 0;
}

function normaliseCounts(counts) {
return Object.fromEntries(reactions.map((reaction) => [reaction, count(counts?.[reaction])]));
}

function normaliseStats(stats) {
return {
totals: normaliseCounts(stats?.totals),
persisted: normaliseCounts(stats?.persisted),
pending: normaliseCounts(stats?.pending),
};
}

function endpoint(room, suffix) {
return `/rooms/${encodeURIComponent(room)}${suffix}`;
}

function setStatus(message) {
appStatus.textContent = message;
}

function setConnection(text, status) {
connectionText.textContent = text;
connection.dataset.state = status;
}

function setControlsDisabled(disabled) {
reactionButtons.forEach((button) => { button.disabled = disabled; });
}

function renderStats() {
const accepted = reactions.reduce((total, reaction) => total + state.stats.totals[reaction], 0);
const pending = reactions.reduce((total, reaction) => total + state.stats.pending[reaction], 0);
totalAccepted.textContent = accepted.toLocaleString();

reactions.forEach((reaction) => {
const row = statsBody.querySelector(`[data-reaction="${reaction}"]`);
["totals", "persisted", "pending"].forEach((field) => {
row.querySelector(`[data-field="${field}"]`).textContent = state.stats[field][reaction].toLocaleString();
});
});

batchCycle.classList.toggle("is-buffered", pending > 0);
batchStatus.textContent = pending > 0
? `${pending.toLocaleString()} reaction${pending === 1 ? "" : "s"} waiting for the next flush.`
: "No reactions waiting to be persisted.";
}

async function requestJson(url, options = {}) {
const response = await fetch(url, options);
let data;
try {
data = await response.json();
} catch {
throw new Error(`The server returned ${response.status} without JSON.`);
}
if (!response.ok) throw new Error(data?.error || `Request failed with status ${response.status}.`);
return data;
}

function stopPolling() {
if (state.pollId !== null) window.clearInterval(state.pollId);
if (state.statsController !== null) state.statsController.abort();
state.pollId = null;
state.statsController = null;
state.polling = false;
}

async function loadStats(initial = false, generation = state.generation) {
const room = state.room;
if (!room || state.polling) return;

state.polling = true;
const controller = new AbortController();
state.statsController = controller;
if (initial) {
setConnection("Connecting…", "loading");
setStatus(`Joining ${room} and loading its reaction ledger.`);
}

try {
const stats = await requestJson(endpoint(room, "/stats"), { signal: controller.signal });
if (state.room !== room || state.generation !== generation) return;
state.stats = normaliseStats(stats);
renderStats();
lastSynced.textContent = `Last updated ${new Date().toLocaleTimeString()} · polling every second`;
setConnection("Live", "live");
if (initial) setStatus(`Joined ${room}. Reaction controls are ready.`);
} catch (error) {
if (error.name !== "AbortError" && state.room === room && state.generation === generation) {
setConnection("Connection issue", "error");
lastSynced.textContent = "Waiting to retry in the next poll.";
setStatus(`Could not load ${room}: ${error.message}`);
}
} finally {
if (state.statsController === controller) {
state.statsController = null;
state.polling = false;
}
}
}

async function joinRoom(event) {
event.preventDefault();
if (!roomForm.reportValidity()) return;

const room = roomInput.value.trim();
if (!roomPattern.test(room)) {
setStatus("Room names use 1–64 letters, numbers, hyphens, or underscores.");
return;
}

stopPolling();
state.generation += 1;
const generation = state.generation;
state.room = room;
state.stats = emptyStats();
activeRoom.textContent = room;
lastSynced.textContent = "Loading room statistics…";
renderStats();
setControlsDisabled(false);

state.pollId = window.setInterval(() => {
void loadStats(false, generation);
}, pollIntervalMs);
await loadStats(true, generation);
}

async function sendReaction(reaction, button) {
const room = state.room;
if (!room || !reactions.includes(reaction)) return;

button.disabled = true;
button.setAttribute("aria-busy", "true");
setConnection("Sending reaction…", "loading");

try {
const result = await requestJson(
endpoint(room, `/reactions/${encodeURIComponent(reaction)}`),
{ method: "POST" },
);
if (state.room !== room) return;
state.stats = {
...state.stats,
totals: normaliseCounts(result.totals),
pending: normaliseCounts(result.pending),
};
renderStats();
setConnection("Live", "live");
setStatus(`${reaction[0].toUpperCase()}${reaction.slice(1)} accepted in ${room}.`);
void loadStats();
} catch (error) {
if (state.room === room) {
setConnection("Connection issue", "error");
setStatus(`Could not send ${reaction}: ${error.message}`);
}
} finally {
button.removeAttribute("aria-busy");
if (state.room === room) button.disabled = false;
}
}

roomForm.addEventListener("submit", joinRoom);
reactionButtons.forEach((button) => {
button.addEventListener("click", () => { void sendReaction(button.dataset.reaction, button); });
});
89 changes: 89 additions & 0 deletions hyperdrive-batched-counter/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="A Durable Object and Hyperdrive batching demo.">
<title>Reaction Counter</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="./style.css">
<script src="./app.js" defer></script>
</head>
<body>
<main class="control-room">
<header class="masthead">
<div class="masthead-row">
<h1>Reaction <em>Counter</em></h1>
</div>
</header>

<section class="panel room-panel" aria-labelledby="room-heading">
<div>
<p class="eyebrow">Channel selection</p>
<h2 id="room-heading">Tune into a room</h2>
</div>
<form id="room-form" class="room-form">
<label for="room-input">Room name</label>
<div class="room-input-row">
<input id="room-input" name="room" value="demo-room" maxlength="64" autocomplete="off" aria-describedby="room-help" required>
<button class="join-button" type="submit">Join room</button>
</div>
</form>
<p id="connection" class="connection" data-state="idle"><span aria-hidden="true"></span><span id="connection-text">Not joined</span></p>
</section>

<section class="panel" aria-labelledby="controls-heading">
<div class="section-heading">
<div>
<p class="eyebrow">On-air controls</p>
<h2 id="controls-heading">Send a reaction</h2>
</div>
<p class="room-readout">Room: <strong id="active-room">—</strong></p>
</div>
<div class="reaction-controls" aria-label="Reaction controls">
<button class="reaction-button" type="button" data-reaction="heart" disabled><span aria-hidden="true">❤️</span>Heart</button>
<button class="reaction-button" type="button" data-reaction="laugh" disabled><span aria-hidden="true">😂</span>Laugh</button>
<button class="reaction-button" type="button" data-reaction="fire" disabled><span aria-hidden="true">🔥</span>Fire</button>
</div>
</section>

<section class="panel" aria-labelledby="ledger-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Signal ledger</p>
<h2 id="ledger-heading">Reaction counts</h2>
</div>
<p id="last-synced" class="sync-note">Join a room to begin polling.</p>
</div>
<div class="total-readout"><span>All accepted</span><strong id="total-accepted">0</strong></div>
<div class="table-wrap">
<table>
<caption class="sr-only">Reaction totals, database-persisted counts, and pending counts</caption>
<thead><tr><th scope="col">Reaction</th><th scope="col">Accepted</th><th scope="col">Persisted</th><th scope="col">Pending</th></tr></thead>
<tbody id="stats-body">
<tr data-reaction="heart"><th scope="row">Heart</th><td data-field="totals">—</td><td data-field="persisted">—</td><td data-field="pending">—</td></tr>
<tr data-reaction="laugh"><th scope="row">Laugh</th><td data-field="totals">—</td><td data-field="persisted">—</td><td data-field="pending">—</td></tr>
<tr data-reaction="fire"><th scope="row">Fire</th><td data-field="totals">—</td><td data-field="persisted">—</td><td data-field="pending">—</td></tr>
</tbody>
</table>
</div>
</section>

<section id="batch-cycle" class="panel batch-cycle" aria-labelledby="batch-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Batch runway</p>
</div>
<p id="batch-status" class="sync-note">No reactions waiting to be persisted.</p>
</div>
<div class="cycle-track" role="img" aria-label="Pending reactions are buffered for up to five seconds before a database write.">
<span class="cycle-sweep" aria-hidden="true"></span>
<span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
</div>
</section>

<p id="app-status" class="app-status" role="status" aria-live="polite"></p>
<noscript><p class="app-status">This page needs JavaScript to send and poll reactions.</p></noscript>
</main>
</body>
</html>
Loading
Loading