Appearance
Backend Considerations
Overview
This document outlines the key technical considerations and patterns implemented in Oktuple's backend architecture to ensure scalability, consistency, and optimal performance across the distributed system.
1. Saga Pattern for Data Consistency
Overview
The Saga pattern is implemented to maintain data consistency across multiple databases and services without relying on distributed transactions, which can be complex and performance-intensive.
Implementation Strategy
Choreography-Based Saga
Compensation Actions
Rollback Strategy:
- If Elasticsearch indexing fails, retry with exponential backoff
- If Typesense indexing fails, mark for later retry
- If both fail, log error and alert administrators
- PostgreSQL remains the source of truth
Retry Mechanisms:
typescript
interface SagaStep {
id: string;
action: () => Promise<void>;
compensation: () => Promise<void>;
maxRetries: number;
retryDelay: number;
}
class SagaOrchestrator {
async executeSaga(steps: SagaStep[]): Promise<void> {
const completedSteps: SagaStep[] = [];
try {
for (const step of steps) {
await this.executeWithRetry(step);
completedSteps.push(step);
}
} catch (error) {
// Compensate in reverse order
for (const step of completedSteps.reverse()) {
await step.compensation();
}
throw error;
}
}
}Benefits
- Eventual Consistency: Guarantees data consistency across all systems
- Fault Tolerance: Handles partial failures gracefully
- Performance: No distributed locks or two-phase commits
- Scalability: Each step can be processed independently
2. Hybrid WebSocket + REST Architecture
Design Philosophy
Oktuple implements a hybrid approach combining REST APIs for standard operations with WebSocket connections for real-time updates, providing both reliability and responsiveness.
Architecture Overview
REST API Usage
When to Use REST:
- CRUD Operations: Create, read, update, delete operations
- File Uploads: Large file transfers
- Authentication: Login, logout, token refresh
- Complex Queries: Search, filtering, pagination
- Batch Operations: Bulk data operations
REST API Characteristics:
typescript
// Standard REST endpoint
POST /api/v1/issues
{
"title": "New Issue",
"description": "Issue description",
"assigneeId": "user_123",
"projectId": "proj_456"
}
// Response
{
"id": "issue_789",
"title": "New Issue",
"status": "open",
"createdAt": "2024-01-15T10:30:00Z"
}WebSocket Usage
When to Use WebSocket:
- Real-time Updates: Live collaboration features
- Notifications: Instant alerts and messages
- Live Cursors: Real-time cursor positions
- Status Updates: Progress indicators and status changes
- Presence: User online/offline status
WebSocket Message Types:
typescript
interface WebSocketMessage {
type: 'update' | 'notification' | 'presence' | 'cursor';
payload: any;
timestamp: string;
userId: string;
}
// Real-time issue update
{
"type": "update",
"payload": {
"entity": "issue",
"id": "issue_789",
"changes": {
"status": "in_progress",
"assigneeId": "user_123"
}
},
"timestamp": "2024-01-15T10:31:00Z",
"userId": "user_123"
}Implementation Strategy
Connection Management
typescript
class WebSocketManager {
private connections = new Map<string, WebSocket>();
async handleConnection(ws: WebSocket, userId: string) {
this.connections.set(userId, ws);
// Subscribe to user-specific channels
await this.subscribeToChannels(userId);
ws.on("close", () => {
this.connections.delete(userId);
this.unsubscribeFromChannels(userId);
});
}
async broadcastToProject(projectId: string, message: WebSocketMessage) {
const projectUsers = await this.getProjectUsers(projectId);
for (const userId of projectUsers) {
const ws = this.connections.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
}
}Fallback Strategy
typescript
class HybridClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
async connect() {
try {
this.ws = new WebSocket("wss://api.oktuple.com/ws");
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.subscribeToUpdates();
};
this.ws.onclose = () => {
this.handleReconnection();
};
} catch (error) {
// Fallback to polling
this.startPolling();
}
}
private handleReconnection() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, Math.pow(2, this.reconnectAttempts) * 1000);
} else {
// Fallback to REST polling
this.startPolling();
}
}
}Benefits
- Reliability: REST provides fallback when WebSocket fails
- Performance: WebSocket reduces server load for real-time features
- User Experience: Instant updates with graceful degradation
- Scalability: Can scale WebSocket and REST independently
3. NanoID for Unique Identifiers
Overview
NanoID is used throughout the system for generating unique, URL-safe identifiers that are more efficient than UUIDs and provide better performance characteristics.
Why NanoID?
Performance Benefits:
- Faster Generation: 2x faster than UUID v4
- Smaller Size: 21 characters vs 36 characters for UUID
- URL-Safe: No special characters that need encoding
- Collision Resistant: 1 in a billion chance of collision
Comparison:
typescript
// UUID v4
const uuid = "550e8400-e29b-41d4-a716-446655440000"; // 36 chars
// NanoID
const nanoid = "V1StGXR8_Z5jdHi6B-myT"; // 21 charsImplementation
Custom NanoID Configuration
typescript
import { customAlphabet } from "nanoid";
// Custom alphabet for better readability
const alphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const nanoid = customAlphabet(alphabet, 12);
// Generate IDs
const userId = `user_${nanoid()}`; // user_V1StGXR8_Z5j
const projectId = `proj_${nanoid()}`; // proj_dHi6B-myT123
const issueId = `issue_${nanoid()}`; // issue_ABC123def456Database Schema Integration
sql
-- PostgreSQL table with NanoID
CREATE TABLE issues (
id VARCHAR(21) PRIMARY KEY DEFAULT 'issue_' || nanoid(),
title VARCHAR(255) NOT NULL,
description TEXT,
project_id VARCHAR(21) REFERENCES projects(id),
created_at TIMESTAMP DEFAULT NOW()
);
-- Index for performance
CREATE INDEX idx_issues_project_id ON issues(project_id);
CREATE INDEX idx_issues_created_at ON issues(created_at);Type Safety
typescript
// Type definitions
type UserID = `user_${string}`;
type ProjectID = `proj_${string}`;
type IssueID = `issue_${string}`;
interface Issue {
id: IssueID;
title: string;
projectId: ProjectID;
assigneeId?: UserID;
}
// ID validation
function isValidId(id: string, prefix: string): boolean {
return id.startsWith(prefix) && id.length === prefix.length + 12;
}Benefits
- Performance: Faster generation and smaller storage
- Readability: Shorter, more readable identifiers
- URL-Safe: No encoding issues in URLs
- Collision Resistant: Extremely low collision probability
4. Lexorank for High-Domain Positioning
Overview
Lexorank is used for maintaining ordered lists with high domain for insertions, allowing efficient reordering of items without updating all subsequent items.
Why Lexorank?
Traditional Approaches Problems:
- Sequential Numbers: Requires updating all items after insertion
- Decimal Numbers: Limited precision, requires rebalancing
- Timestamps: Not suitable for manual ordering
Lexorank Benefits:
- High Domain: 62^26 possible positions between any two ranks
- Efficient Insertion: O(1) insertion between any two items
- No Rebalancing: Rarely needs to rebalance the entire list
- Collision Resistant: Extremely low probability of collision
Implementation
Rank Generation
typescript
class Lexorank {
private static readonly ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static readonly BASE = 62;
static generateRank(prevRank?: string, nextRank?: string): string {
if (!prevRank && !nextRank) {
return this.getMiddleRank();
}
if (!prevRank) {
return this.getRankBefore(nextRank!);
}
if (!nextRank) {
return this.getRankAfter(prevRank);
}
return this.getRankBetween(prevRank, nextRank);
}
private static getMiddleRank(): string {
return "U".repeat(13); // Middle of the alphabet
}
private static getRankBefore(rank: string): string {
const rankValue = this.rankToNumber(rank);
const newValue = Math.floor(rankValue / 2);
return this.numberToRank(newValue);
}
private static getRankAfter(rank: string): string {
const rankValue = this.rankToNumber(rank);
const newValue = Math.floor((rankValue + this.BASE ** 13) / 2);
return this.numberToRank(newValue);
}
private static getRankBetween(prevRank: string, nextRank: string): string {
const prevValue = this.rankToNumber(prevRank);
const nextValue = this.rankToNumber(nextRank);
if (nextValue - prevValue <= 1) {
// Need to rebalance
return this.rebalanceRanks(prevRank, nextRank);
}
const newValue = Math.floor((prevValue + nextValue) / 2);
return this.numberToRank(newValue);
}
private static rankToNumber(rank: string): number {
let value = 0;
for (let i = 0; i < rank.length; i++) {
value = value * this.BASE + this.ALPHABET.indexOf(rank[i]);
}
return value;
}
private static numberToRank(value: number): string {
let rank = "";
while (value > 0) {
rank = this.ALPHABET[value % this.BASE] + rank;
value = Math.floor(value / this.BASE);
}
return rank.padStart(13, "0");
}
}Database Integration
sql
-- Issues table with Lexorank
CREATE TABLE issues (
id VARCHAR(21) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
rank VARCHAR(13) NOT NULL,
project_id VARCHAR(21) REFERENCES projects(id),
created_at TIMESTAMP DEFAULT NOW()
);
-- Index for efficient ordering
CREATE INDEX idx_issues_project_rank ON issues(project_id, rank);API Usage
typescript
// Move issue to new position
async function moveIssue(issueId: string, newPosition: number) {
const issue = await getIssue(issueId);
const projectIssues = await getProjectIssues(issue.projectId);
// Sort by rank
projectIssues.sort((a, b) => a.rank.localeCompare(b.rank));
let prevRank: string | undefined;
let nextRank: string | undefined;
if (newPosition > 0) {
prevRank = projectIssues[newPosition - 1]?.rank;
}
if (newPosition < projectIssues.length - 1) {
nextRank = projectIssues[newPosition + 1]?.rank;
}
const newRank = Lexorank.generateRank(prevRank, nextRank);
await updateIssue(issueId, { rank: newRank });
}Benefits
- Efficient Reordering: O(1) insertion between any two items
- High Domain: 62^26 possible positions
- No Rebalancing: Rarely needs to rebalance entire lists
- Collision Resistant: Extremely low collision probability
5. Unified Query Language
Overview
A unified query language provides a consistent interface for querying across multiple databases (PostgreSQL, Elasticsearch, Typesense) while abstracting the complexity of different query syntaxes.
Design Goals
- Consistency: Same query syntax across all databases
- Type Safety: Compile-time query validation
- Performance: Optimized queries for each database
- Flexibility: Support for complex queries and aggregations
Query Language Specification
Schema Types
typescript
// Define schema types
export type LogicalOperator = "AND" | "OR";
export type Operator =
| "eq"
| "neq"
| "in"
| "nin"
| "lt"
| "lte"
| "gt"
| "gte"
| "bet"
| "sw"
| "ew"
| "like"
| "nlike"
| "nn"
| "null"
| "isem"
| "isnem"
| "hasSome";
export interface FilterItem {
key: string;
op: Operator;
value: any;
}
export interface FilterGroup {
lo: LogicalOperator;
items: (FilterItem | FilterGroup)[];
}
export interface SortItem {
key: string;
order: "asc" | "desc";
}
export interface QueryFetch {
text_search?: string;
filters?: FilterGroup;
grouping?: GroupingOptions;
order?: SortItem[];
limit?: number;
offset?: number;
page?: number;
}
export interface TextSearchCols {
textSearchCols?: string[];
}
export type RawQueryFetch = QueryFetch & TextSearchCols;
export type GroupingOptions = {
groupBy: string;
sizePerGroup: number;
includeFields?: string[];
};
export interface QueryResult<T> {
rows: T[];
total: number | any;
// query: Record<string, any>
aggregations?: Record<string, any>;
page: number;
limit: number;
nextToken: string | null;
error: string | null;
}Example Queries
typescript
// Simple query with filters
const query1: QueryFetch = {
text_search: "bug fix",
filters: {
lo: "AND",
items: [
{ key: "projectId", op: "eq", value: "proj_123" },
{ key: "status", op: "in", value: ["open", "in_progress"] },
],
},
order: [{ key: "createdAt", order: "desc" }],
limit: 20,
page: 1,
};
// Complex nested filter query
const query2: QueryFetch = {
filters: {
lo: "AND",
items: [
{ key: "projectId", op: "eq", value: "proj_123" },
{
lo: "OR",
items: [
{ key: "priority", op: "eq", value: "high" },
{ key: "assigneeId", op: "nn", value: null },
],
},
],
},
grouping: {
groupBy: "status",
sizePerGroup: 10,
includeFields: ["id", "title", "assigneeId"],
},
order: [{ key: "createdAt", order: "desc" }],
limit: 50,
};
// Text search with specific columns
const query3: RawQueryFetch = {
text_search: "authentication",
textSearchCols: ["title", "description", "comments"],
filters: {
lo: "AND",
items: [
{ key: "projectId", op: "eq", value: "proj_456" },
{ key: "createdAt", op: "gte", value: "2024-01-01" },
],
},
order: [{ key: "relevance", order: "desc" }],
limit: 25,
};Benefits
- Consistency: Same query syntax across all databases
- Type Safety: Compile-time validation of queries
- Performance: Optimized queries for each database type
- Maintainability: Single query interface to maintain
- Flexibility: Easy to add new database support
3. Configuration Management
typescript
interface BackendConfig {
saga: {
maxRetries: number;
retryDelay: number;
compensationTimeout: number;
};
websocket: {
heartbeatInterval: number;
maxReconnectAttempts: number;
reconnectDelay: number;
};
nanoid: {
alphabet: string;
length: number;
};
lexorank: {
alphabet: string;
base: number;
length: number;
};
query: {
defaultLimit: number;
maxLimit: number;
timeout: number;
};
}Conclusion
These backend considerations form the foundation of Oktuple's robust, scalable architecture. The combination of Saga patterns for consistency, hybrid WebSocket/REST for real-time communication, NanoID for efficient identification, Lexorank for flexible ordering, and a unified query language for database abstraction creates a powerful and maintainable system that can scale with growing requirements.
Each pattern addresses specific challenges in distributed systems while maintaining simplicity and performance. The implementation details provided serve as a guide for developers working with the system and ensure consistent application of these patterns across the codebase.