Use Valkey instead of Redis

Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3. None of the three works
for a closed-source commercial cloud service, and SSPL section 13
specifically targets offering the software's functionality as a service,
which is what a control plane does. Valkey is BSD-3-Clause, under the
Linux Foundation, forked from Redis 7.2.4.

Deciding this now rather than after step 3 of PLAN.md stands up real
infrastructure makes it free: Valkey speaks the same RESP protocol, so
`ioredis` is untouched and only names change. `REDIS_URL` becomes
`VALKEY_URL`, the exported client becomes `valkey`, and `/health`
reports a `valkey` field.

Two things deliberately do not change, documented in place so they are
not "fixed" later: the `redis://` URL scheme, which names the wire
protocol and is what every client actually parses (none understands
`valkey://`), and `ioredis` itself, which is a RESP client whose name
refers to the protocol's origin and not to the server we run.

docker-compose.yml is new, pinning valkey/valkey:8-alpine alongside
pgvector/pgvector:pg17 - the plain postgres image lacks the extension
`frame_embeddings` needs. It makes the Valkey choice concrete rather
than a note someone has to remember.
This commit is contained in:
2026-10-03 10:10:28 +00:00
parent 349c4073cd
commit ab093f3136
5 changed files with 69 additions and 11 deletions
+6 -2
View File
@@ -4,8 +4,12 @@ PORT=3000
DATABASE_URL=postgres://continuum:continuum@localhost:6432/continuum
DATABASE_POOL_MAX=10
# Redis (state + streams)
REDIS_URL=redis://localhost:6379
# Valkey (state + streams), NOT Redis - Redis 7.4+ is RSALv2/SSPL and 8.0+
# adds AGPLv3, none of which we can ship commercially. Valkey is BSD-3-Clause
# and wire-compatible. The URL scheme stays `redis://` because that names the
# RESP wire protocol, which is what the client libraries parse - no client
# understands a `valkey://` scheme.
VALKEY_URL=redis://localhost:6379
# Cloudflare R2 (S3-compatible)
R2_ACCOUNT_ID=
+10 -4
View File
@@ -1,13 +1,13 @@
# continuum-backend
Cloud control plane for Continuum. Bun + ElysiaJS, PostgreSQL (via pgCat/PgBouncer,
with pgvector for frame embeddings), Redis Streams, and Cloudflare R2 storage.
with pgvector for frame embeddings), Valkey Streams, and Cloudflare R2 storage.
## Run
```sh
bun install
cp .env.example .env # fill in DATABASE_URL, REDIS_URL, R2_*, SUPABASE_JWT_SECRET
cp .env.example .env # fill in DATABASE_URL, VALKEY_URL, R2_*, SUPABASE_JWT_SECRET
bun run db:generate && bun run db:migrate
bun run dev
```
@@ -21,8 +21,14 @@ bun run dev
instead of two hand-synced ones.
- `src/s3/` - R2 presigned URL helpers (`PUT` for client uploads, `GET` for the
edge proxy).
- `src/queue/` - Redis client + `XADD` helper pushing onto `stream:camera_frames`
for `continuum-ai-worker`.
- `src/queue/` - Valkey client (`ioredis`) + `XADD` helper pushing onto
`stream:camera_frames` for `continuum-ai-worker`.
Valkey, not Redis: Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3, none of
which a commercial product can ship. Valkey is BSD-3-Clause and speaks the
same RESP protocol, so `ioredis` is unchanged - only the server differs. See
`continuum-common/docs/LICENSES.md`. `docker-compose.yml` here brings up
Valkey and Postgres+pgvector for local development.
- `src/routes/` - `/auth`, `/farms`, `/printers`, `/jobs`, and the edge-gateway
WebSocket at `/ws/edge/v1`.
+45
View File
@@ -0,0 +1,45 @@
# Local development dependencies for continuum-backend.
#
# Valkey rather than Redis on purpose: Redis 7.4+ is RSALv2/SSPL and 8.0+
# adds AGPLv3, none of which a closed-source commercial product can ship.
# Valkey is BSD-3-Clause and speaks the same RESP protocol, so both clients
# (`ioredis` here, the `redis` crate in continuum-ai-worker) are unchanged.
# See continuum-common/docs/LICENSES.md.
#
# pgCat/PgBouncer is deliberately not here - DATABASE_URL in .env.example
# points at :6432 for production, but talking straight to Postgres on :5432
# is one less moving part locally.
services:
valkey:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
command: ["valkey-server", "--appendonly", "yes"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
retries: 5
postgres:
# pgvector/pgvector ships the extension prebuilt; the plain postgres
# image does not, and `frame_embeddings` needs it.
image: pgvector/pgvector:pg17
ports:
- "5432:5432"
environment:
POSTGRES_USER: continuum
POSTGRES_PASSWORD: continuum
POSTGRES_DB: continuum
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U continuum -d continuum"]
interval: 5s
retries: 5
volumes:
valkey-data:
postgres-data:
+3 -3
View File
@@ -3,7 +3,7 @@ import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import { sql } from "drizzle-orm";
import { db } from "./db";
import { redis } from "./queue";
import { valkey } from "./queue";
import { authRoutes } from "./routes/auth";
import { farmRoutes } from "./routes/farms";
import { printerRoutes } from "./routes/printers";
@@ -15,8 +15,8 @@ const app = new Elysia()
.use(swagger({ path: "/docs" }))
.get("/health", async () => {
const rows = await db.execute<{ ok: number }>(sql`select 1 as ok`);
const redisPing = await redis.ping();
return { status: "ok", db: rows[0]?.ok === 1, redis: redisPing === "PONG" };
const valkeyPing = await valkey.ping();
return { status: "ok", db: rows[0]?.ok === 1, valkey: valkeyPing === "PONG" };
})
.use(authRoutes)
.use(farmRoutes)
+5 -2
View File
@@ -1,6 +1,9 @@
// ioredis talks RESP, so it drives Valkey unchanged - see docs/LICENSES.md in
// continuum-common for why the server is Valkey and not Redis. The `redis://`
// scheme names the wire protocol, not the product.
import Redis from "ioredis";
export const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
export const valkey = new Redis(process.env.VALKEY_URL ?? "redis://localhost:6379", {
maxRetriesPerRequest: 3,
});
@@ -16,7 +19,7 @@ export interface FrameIngestionTask {
/** Pushes a frame-ingestion task for continuum-ai-worker to pick up via XREADGROUP. */
export async function enqueueFrameIngestion(task: FrameIngestionTask): Promise<string> {
return redis.xadd(
return valkey.xadd(
CAMERA_FRAMES_STREAM,
"*",
"farmId",