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
2 changes: 1 addition & 1 deletion .github/workflows/compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-

- run: bun install
- run: bun install --frozen-lockfile

- name: Install latest @upstash/vector
run: bun add -d @upstash/vector@latest
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/maintenance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ jobs:
with:
bun-version: latest

- run: bun install
- run: bun install --frozen-lockfile
- name: Bun version under test
run: bun --version

Expand Down Expand Up @@ -177,7 +177,7 @@ jobs:
with:
bun-version: ${{ env.BUN_PIN }}

- run: bun install
- run: bun install --frozen-lockfile

- name: Start up-vector
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-

- run: bun install
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun run lint
- run: bun test tests/unit
Expand Down Expand Up @@ -66,7 +66,7 @@ jobs:
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-

- run: bun install
- run: bun install --frozen-lockfile

- name: Start up-vector
run: |
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ bun run typecheck # tsc --noEmit
### Docker

```bash
docker compose up -d # Production (up-vector + Redis Stack)
docker compose up -d # Production (network-internal by design; Coolify/proxy fronts it — see README override for host access)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up # Dev (watch mode, debug logs)
```

Expand Down Expand Up @@ -154,7 +154,7 @@ All prefixed `UPVECTOR_`:
## Implementation Status

Phases 1-6 complete. Dense-vector CRUD + query + filtering + namespaces + production hardening + provider-backed `/upsert-data`/`/query-data`.
383 tests passing (249 unit, 60 integration, 74 SDK compatibility).
444 tests passing (310 unit, 60 integration, 74 SDK compatibility).
Production hardening includes structured JSON logging, graceful shutdown, health probes, request timeouts, Prometheus metrics (optional scrape token), body limits, binary vector round-trip protection, Redis client self-heal, and process-level error handlers.
See `PLAN.md` for the full architecture and phase breakdown.

Expand Down
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ RUN apk add --no-cache curl
# upstream image. Running as root would let a process escape into the rest of
# the filesystem if it ever found an arbitrary-write bug.
COPY --from=builder --chown=bun:bun /app/dist ./dist
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
COPY --from=builder --chown=bun:bun /app/package.json ./

USER bun
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ cp .env.example .env
docker compose up -d
```

The API is now available at `http://localhost:8080`.
The stack intentionally publishes **no host port** (`expose` only), so platform proxies
like [Coolify](https://coolify.io) can join the Docker network and front the service directly.
To reach it from your own machine instead, drop in an override before `up -d`:

```yaml
# docker-compose.override.yml
services:
up-vector:
ports: ["${UPVECTOR_PORT:-8080}:8080"]
```

The API is then available at `http://localhost:8080`.

## Usage with @upstash/vector

Expand Down Expand Up @@ -314,7 +325,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and contribution

```bash
cp .env.example .env # Set UPVECTOR_TOKEN
docker compose up -d # Starts up-vector + Redis Stack
docker compose up -d # Starts up-vector + Redis Stack (network-internal; see Quick Start for host access)
```

### With up-redis (side-by-side)
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
153 changes: 124 additions & 29 deletions src/embedding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,56 @@ type OpenAIEmbeddingResponse = {
}
}

/**
* Provider-side /embeddings requests have per-request budgets (~300k tokens on
* OpenAI); a locally-valid batch (up to 1000 inputs x up to ~1M chars each)
* can deterministically exceed them and fail the whole batch with a
* non-retryable 400. embedMany splits inputs into chunks bounded by both:
*/
export const EMBED_MAX_ITEMS_PER_REQUEST = 256
export const EMBED_MAX_CHARS_PER_REQUEST = 250_000

/**
* Split inputs into provider-request-sized chunks (greedy: whichever bound —
* item count or total characters — fills first closes a chunk). A single
* oversized input still forms its own chunk; the provider enforces per-item
* limits. Chunk boundaries preserve input order.
*/
export function chunkEmbeddingInputs(inputs: string[]): string[][] {
const chunks: string[][] = []
let current: string[] = []
let currentChars = 0
for (const input of inputs) {
if (
current.length > 0 &&
(current.length === EMBED_MAX_ITEMS_PER_REQUEST ||
currentChars + input.length > EMBED_MAX_CHARS_PER_REQUEST)
) {
chunks.push(current)
current = []
currentChars = 0
}
current.push(input)
currentChars += input.length
}
if (current.length > 0) chunks.push(current)
return chunks
}

/**
* An in-flight provider request plus its deadline handle. The abort timer
* stays armed until `settle()` is called after the response BODY is fully
* consumed — a provider can deliver headers promptly and then stall mid-body,
* which would otherwise hang forever despite UPVECTOR_EMBEDDING_TIMEOUT_MS.
*/
type RequestDeadline = {
response: Response
/** Disarm the deadline (idempotent). Must be called exactly once, after body consumption. */
settle: () => void
/** Whether the deadline fired before settle(). */
timedOut: () => boolean
}

export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider {
readonly name = "openai"
readonly model: string
Expand All @@ -110,29 +160,49 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider {
async embedMany(inputs: string[]): Promise<number[][]> {
if (inputs.length === 0) return []

const chunks = chunkEmbeddingInputs(inputs)
// Chunks are embedded sequentially so strict input→embedding order holds;
// all-or-nothing semantics: any chunk that exhausts its retries fails
// the entire call.
const embeddings: number[][] = []
for (const chunk of chunks) {
embeddings.push(...(await this.embedChunk(chunk)))
}
return embeddings
}

private async embedChunk(chunk: string[]): Promise<number[][]> {
for (let attempt = 0; attempt <= this.retries; attempt++) {
try {
const response = await this.request(inputs)
if (response.ok) {
return await this.parseResponse(response, inputs.length)
}
const deadline = await this.request(chunk)
try {
if (deadline.response.ok) {
return await this.underDeadline(deadline, () =>
this.parseResponse(deadline.response, chunk.length),
)
}

if (isRetryableStatus(response.status) && attempt < this.retries) {
await sleep(this.retryDelayMs(attempt, response.headers.get("retry-after")))
continue
}
if (isRetryableStatus(deadline.response.status) && attempt < this.retries) {
await sleep(this.retryDelayMs(attempt, deadline.response.headers.get("retry-after")))
continue
}

const message = await readProviderError(response)
if (message) {
log.warn("embedding provider error", {
status: response.status,
providerMessage: message,
})
const message = await this.underDeadline(deadline, () =>
readProviderError(deadline.response),
)
if (message) {
log.warn("embedding provider error", {
status: deadline.response.status,
providerMessage: message,
})
}
throw new EmbeddingProviderError(
`Embedding provider failed with HTTP ${deadline.response.status}`,
502,
)
} finally {
deadline.settle()
}
throw new EmbeddingProviderError(
`Embedding provider failed with HTTP ${response.status}`,
502,
)
} catch (err) {
if (err instanceof EmbeddingProviderError) {
if (err.status === 504 && attempt < this.retries) {
Expand All @@ -152,7 +222,7 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider {
throw new EmbeddingProviderError("Embedding provider request failed", 502)
}

private async request(inputs: string[]): Promise<Response> {
private async request(inputs: string[]): Promise<RequestDeadline> {
const body: Record<string, unknown> = {
model: this.model,
input: inputs,
Expand All @@ -161,30 +231,55 @@ export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider {
body.dimensions = this.dimension
}

const init = (): RequestInit => ({
method: "POST",
headers: this.headers(),
body: JSON.stringify(body),
})

if (this.timeoutMs === 0) {
return this.fetchFn(`${this.baseUrl}/embeddings`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(body),
})
// Timeout disabled: no controller, nothing to arm or settle.
const response = await this.fetchFn(`${this.baseUrl}/embeddings`, init())
return { response, settle: () => {}, timedOut: () => false }
}

const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), this.timeoutMs)
try {
return await this.fetchFn(`${this.baseUrl}/embeddings`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(body),
const response = await this.fetchFn(`${this.baseUrl}/embeddings`, {
...init(),
signal: controller.signal,
})
// Keep the deadline armed across body reads; see RequestDeadline.
return {
response,
settle: () => clearTimeout(timeout),
timedOut: () => controller.signal.aborted,
}
} catch (err) {
clearTimeout(timeout)
if (controller.signal.aborted) {
throw new EmbeddingProviderError("Embedding provider timed out", 504)
}
throw err
}
}

/**
* Read the response body under the still-armed request deadline. A stalled
* body surfaces through the standard timeout error (504), which feeds the
* existing retry logic.
*/
private async underDeadline<T>(deadline: RequestDeadline, read: () => Promise<T>): Promise<T> {
try {
return await read()
} catch (err) {
if (deadline.timedOut()) {
throw new EmbeddingProviderError("Embedding provider timed out", 504)
}
throw err
} finally {
clearTimeout(timeout)
deadline.settle()
}
}

Expand Down
Loading