Skip to content

feat: implement background streaming with Convex actions - #520

Merged
leoisadev1 merged 3 commits into
mainfrom
feat/background-streaming
Jan 19, 2026
Merged

feat: implement background streaming with Convex actions#520
leoisadev1 merged 3 commits into
mainfrom
feat/background-streaming

Conversation

@leoisadev1

Copy link
Copy Markdown
Member

Summary

  • Implement background streaming using Convex actions that survive page reloads
  • Add streamJobs table to track streaming jobs in Convex
  • Refactor use-persistent-chat hook to use Convex-based streaming instead of HTTP
  • Remove skeleton loader for faster perceived loading
  • Add Redis client and typing endpoint for future features

Changes

Backend (Convex)

  • schema.ts: Added streamJobs table with indexes for chat, user, and status
  • backgroundStream.ts: New module with:
    • startStream mutation - creates job and schedules action
    • executeStream action - calls OpenRouter API independently of client connection
    • updateStreamContent - batches content updates every 5 tokens
    • completeStream / failStream - handles completion and errors
    • getActiveStreamJob query - enables resuming on page reload

Frontend

  • use-persistent-chat.ts: Refactored to use Convex subscriptions for streaming state
  • chat-interface.tsx: Removed skeleton loader, show nothing while loading existing chats
  • redis.ts: Redis client for future caching features
  • typing.ts: Typing indicator endpoint

How it works

  1. User sends message → startBackgroundStream mutation called
  2. Convex action runs independently, streams from OpenRouter
  3. Client subscribes to job via getActiveStreamJob query
  4. Content updates flow through Convex reactive subscription
  5. On page reload, subscription reconnects and shows current content

Testing

  • Type check passes
  • Build passes
  • Tested streaming locally
  • Tested page reload during stream

- Add streamJobs table to Convex schema for tracking stream jobs
- Create backgroundStream.ts with Convex action that calls OpenRouter independently
- Refactor use-persistent-chat hook to use Convex-based streaming
- Remove skeleton loader for faster perceived loading
- Add Redis client and typing endpoint for future features
- Streaming now survives page reloads via Convex subscriptions
@railway-app

railway-app Bot commented Jan 19, 2026

Copy link
Copy Markdown

🚅 Deployed to the openchat-pr-520 environment in OpenChat

Service Status Web Updated (UTC)
web ✅ Success (View Logs) Web Jan 19, 2026 at 9:47 pm

@railway-app
railway-app Bot temporarily deployed to OpenChat / openchat-pr-520 January 19, 2026 21:36 Destroyed
@github-actions

github-actions Bot commented Jan 19, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment Ready

Environment URL
Frontend https://web-openchat-pr-520.up.railway.app
Convex Dashboard Dashboard

Convex Preview Backend

  • Cloud URL: https://brainy-dogfish-632.convex.cloud
  • Site URL: https://brainy-dogfish-632.convex.site

🤖 Deployed automatically by GitHub Actions

@greptile-apps

greptile-apps Bot commented Jan 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR successfully migrates chat streaming from HTTP endpoints to Convex background actions, enabling streams to survive page reloads. The core implementation is solid with reactive subscriptions replacing polling.

Key Changes

  • Backend: New streamJobs table tracks streaming state in Convex. The backgroundStream module handles job lifecycle with batched updates (every 5 tokens) to minimize DB writes.
  • Frontend: use-persistent-chat hook refactored to use Convex subscriptions instead of HTTP streaming. Removed skeleton loaders for instant navigation.
  • Infrastructure: Redis client and typing endpoint added for future features but not integrated with current streaming implementation.

Issues Found

  • Silent error handling: Parse errors in SSE processing are completely ignored (backgroundStream.ts:368), making debugging difficult
  • Redis KEYS command: Uses blocking KEYS operation instead of SCAN (redis.ts:220-223), which can cause performance issues at scale
  • Message history incomplete: Reasoning parts excluded when building conversation context (use-persistent-chat.ts:273-277), potentially reducing follow-up response quality
  • Race condition potential: Multiple concurrent startStream calls could both set activeStreamId (backgroundStream.ts:49-52)
  • Dead code: The /api/chat HTTP endpoint (364 lines) appears superseded by Convex actions but remains in codebase
  • Unused infrastructure: Entire Redis module (~300 lines) added but not used by background streaming

Recommendations

  • Add error logging for SSE parse failures
  • Replace Redis KEYS with SCAN iterator
  • Include reasoning in message context for better AI responses
  • Add concurrent stream detection in startStream
  • Clarify migration strategy and remove unused code

Confidence Score: 3/5

  • This PR is safe to merge with some technical debt - the core streaming functionality works but has code quality issues
  • Score reflects solid architectural approach (Convex actions for background streaming) and successful implementation of page-reload survival, but offset by silent error handling, unused code (Redis module, HTTP endpoint), and minor logic issues (missing reasoning context, Redis KEYS command). These won't break production but reduce code quality and maintainability.
  • Pay attention to backgroundStream.ts (error handling gaps) and redis.ts (performance issues). Consider cleanup of api/chat.ts if truly deprecated.

Important Files Changed

Filename Overview
apps/server/convex/backgroundStream.ts New background streaming implementation with good architecture but has error handling gaps (silent catch blocks, no timeout handling) and potential API key exposure through scheduler
apps/web/src/hooks/use-persistent-chat.ts Major refactor from HTTP streaming to Convex subscriptions - well implemented but loses reasoning context when building conversation history
apps/web/src/lib/redis.ts Comprehensive Redis client for future features but uses deprecated KEYS command that blocks Redis - entire module unused in this PR
apps/web/src/routes/api/chat.ts HTTP streaming endpoint appears to be superseded by Convex actions - unclear if this is dead code or part of migration strategy

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend as Frontend<br/>(use-persistent-chat)
    participant Convex as Convex DB
    participant Action as Convex Action<br/>(executeStream)
    participant OpenRouter as OpenRouter API

    User->>Frontend: Send message
    Frontend->>Convex: chats.create (if new chat)
    Convex-->>Frontend: chatId
    Frontend->>Convex: messages.send (user message)
    Frontend->>Convex: backgroundStream.startStream
    
    Note over Convex: Creates streamJob<br/>status: "pending"
    Convex->>Convex: Update chat.activeStreamId
    Convex->>Action: Schedule executeStream action
    Convex-->>Frontend: jobId
    
    Frontend->>Convex: Subscribe to getActiveStreamJob query
    
    Action->>Convex: Update job status: "running"
    Action->>OpenRouter: POST /chat/completions (stream: true)
    
    loop Stream tokens
        OpenRouter-->>Action: SSE chunk (delta.content)
        Action->>Action: Accumulate content (batch 5 tokens)
        Action->>Convex: updateStreamContent mutation
        Convex-->>Frontend: Reactive update (via subscription)
        Frontend->>User: Display streaming text
    end
    
    OpenRouter-->>Action: [DONE]
    Action->>Convex: completeStream mutation
    Note over Convex: Insert/update message<br/>Clear activeStreamId<br/>Set job: "completed"
    Convex-->>Frontend: Final reactive update
    Frontend->>User: Show completed message
    
    alt On page reload during stream
        User->>Frontend: Reload page
        Frontend->>Convex: getActiveStreamJob query
        Convex-->>Frontend: Current job with partial content
        Frontend->>User: Resume displaying stream
    end
    
    alt On error
        OpenRouter-->>Action: Error response
        Action->>Convex: failStream mutation
        Convex-->>Frontend: Error status update
        Frontend->>User: Display error
    end
Loading

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

27 files reviewed, 7 comments

Edit Code Review Agent Settings | Greptile

Comment thread apps/server/convex/backgroundStream.ts Outdated
});
updateCounter = 0;
}
} catch {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: silent error swallowing - parsing errors are completely ignored

Suggested change
} catch {}
} catch (parseError) {
console.error("[BackgroundStream] Failed to parse SSE chunk:", data, parseError);
}

Confidence: 4/5 - This is a clear code smell where parsing errors are silently ignored. While it may not cause immediate issues, it makes debugging extremely difficult when malformed SSE data arrives.

Comment on lines +49 to +52
await ctx.scheduler.runAfter(0, internal.backgroundStream.executeStream, {
jobId,
apiKey: args.apiKey,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: race condition - if another stream starts immediately after this one (within milliseconds), both could have activeStreamId set simultaneously

The second startStream call should check if there's already an active stream:

Suggested change
await ctx.scheduler.runAfter(0, internal.backgroundStream.executeStream, {
jobId,
apiKey: args.apiKey,
});
const existingActiveStream = await ctx.db
.query("streamJobs")
.withIndex("by_chat", (q) =>
q.eq("chatId", args.chatId).eq("status", "running")
)
.first();
if (existingActiveStream) {
throw new Error("Stream already in progress for this chat");
}
await ctx.scheduler.runAfter(0, internal.backgroundStream.executeStream, {

Confidence: 2/5 - This is a potential edge case, but concurrent stream starts could cause issues.

Comment on lines +273 to +277
const allMsgs = messages.map((m) => {
const textPart = m.parts?.find((p): p is { type: "text"; text: string } => p.type === "text");
return { role: m.role, content: textPart?.text || "" };
});
allMsgs.push({ role: "user", content: message.text });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: message history excludes reasoning parts - when building the conversation history for the AI, only text parts are extracted, losing reasoning context

Suggested change
const allMsgs = messages.map((m) => {
const textPart = m.parts?.find((p): p is { type: "text"; text: string } => p.type === "text");
return { role: m.role, content: textPart?.text || "" };
});
allMsgs.push({ role: "user", content: message.text });
const allMsgs = messages.map((m) => {
const textPart = m.parts?.find((p): p is { type: "text"; text: string } => p.type === "text");
const reasoningPart = m.parts?.find((p): p is { type: "reasoning"; text: string } => p.type === "reasoning");
const content = [reasoningPart?.text, textPart?.text].filter(Boolean).join("\n\n");
return { role: m.role, content: content || "" };
});

Confidence: 3/5 - Depending on the AI model, excluding reasoning from context might reduce response quality for follow-up messages.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

let fullReasoning = "";
let buffer = "";
let updateCounter = 0;
const UPDATE_INTERVAL = 5;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: hardcoded batch size may cause UI lag - UPDATE_INTERVAL = 5 means updates every 5 tokens, which could feel laggy for fast models

Consider making this configurable or reducing to 3:

Suggested change
const UPDATE_INTERVAL = 5;
const UPDATE_INTERVAL = 3;

Confidence: 2/5 - This is a UX trade-off. Lower values increase DB writes but improve perceived responsiveness.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread apps/web/src/lib/redis.ts
Comment on lines +220 to +223
const pattern = `chat:${chatId}:typing:*`;
const foundKeys = await client.keys(pattern);

return foundKeys.map((key) => key.split(":").pop() || "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: inefficient key scanning - client.keys(pattern) scans all keys and blocks Redis, which can cause performance issues at scale

Suggested change
const pattern = `chat:${chatId}:typing:*`;
const foundKeys = await client.keys(pattern);
return foundKeys.map((key) => key.split(":").pop() || "");
const pattern = `chat:${chatId}:typing:*`;
const foundKeys: string[] = [];
for await (const key of client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
foundKeys.push(key);
}
return foundKeys.map((key) => key.split(":").pop() || "");

Confidence: 4/5 - KEYS command is documented as dangerous in production and should be replaced with SCAN.

Comment on lines +290 to +307
try {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io",
"X-Title": "OSSChat",
},
body: JSON.stringify({
model: job.model,
messages: job.messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
})),
stream: true,
}),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: no timeout for OpenRouter API call - if OpenRouter hangs, the Convex action could run indefinitely, consuming resources

Convex actions have a maximum execution time, but it's better to explicitly handle timeouts:

Suggested change
try {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io",
"X-Title": "OSSChat",
},
body: JSON.stringify({
model: job.model,
messages: job.messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
})),
stream: true,
}),
});
const timeoutMs = 5 * 60 * 1000; // 5 minutes
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io",
"X-Title": "OSSChat",
},
body: JSON.stringify({
model: job.model,
messages: job.messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
})),
stream: true,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);

Confidence: 3/5 - Adds defensive error handling for long-running requests.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +208 to +229
const existingMessage = await ctx.db
.query("messages")
.withIndex("by_client_id", (q) =>
q.eq("chatId", job.chatId).eq("clientMessageId", job.messageId)
)
.first();

if (!existingMessage) {
await ctx.db.insert("messages", {
chatId: job.chatId,
clientMessageId: job.messageId,
role: "assistant",
content: args.content,
reasoning: args.reasoning,
createdAt: Date.now(),
});
} else {
await ctx.db.patch(existingMessage._id, {
content: args.content,
reasoning: args.reasoning,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: duplicate message insertion logic - the completion handler either inserts a new message or updates an existing one, but this duplicate check is unnecessary if clientMessageId is unique

The frontend already creates assistantMsgId with crypto.randomUUID() which should be unique. Consider removing the query and always inserting:

Suggested change
const existingMessage = await ctx.db
.query("messages")
.withIndex("by_client_id", (q) =>
q.eq("chatId", job.chatId).eq("clientMessageId", job.messageId)
)
.first();
if (!existingMessage) {
await ctx.db.insert("messages", {
chatId: job.chatId,
clientMessageId: job.messageId,
role: "assistant",
content: args.content,
reasoning: args.reasoning,
createdAt: Date.now(),
});
} else {
await ctx.db.patch(existingMessage._id, {
content: args.content,
reasoning: args.reasoning,
});
}
await ctx.db.insert("messages", {
chatId: job.chatId,
clientMessageId: job.messageId,
role: "assistant",
content: args.content,
reasoning: args.reasoning,
createdAt: Date.now(),
});

Confidence: 2/5 - This might be defensive programming for edge cases, but adds unnecessary DB queries.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

- Add error logging for SSE parse failures
- Add race condition check for concurrent streams
- Add 5-minute timeout for OpenRouter API calls
- Replace Redis KEYS with SCAN for better performance
@railway-app
railway-app Bot temporarily deployed to OpenChat / openchat-pr-520 January 19, 2026 21:44 Destroyed
@leoisadev1
leoisadev1 merged commit bde1616 into main Jan 19, 2026
5 checks passed
@leoisadev1
leoisadev1 deleted the feat/background-streaming branch January 19, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant