mirror of
https://github.com/DeBrosOfficial/network.git
synced 2026-01-30 01:53:02 +00:00
feat: add network MCP rules and documentation
- Introduced a new `network.mdc` file containing comprehensive guidelines for utilizing the network Model Context Protocol (MCP). - Documented available MCP tools for code understanding, skill learning, and recommended workflows to enhance developer efficiency. - Provided detailed instructions on the collaborative skill learning process and user override commands for better interaction with the MCP.
This commit is contained in:
parent
670c3f99df
commit
ee80be15d8
106
.cursor/rules/network.mdc
Normal file
106
.cursor/rules/network.mdc
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Instructions
|
||||||
|
|
||||||
|
You have access to the **network** MCP (Model Context Protocol) server for this project. This MCP provides deep, pre-analyzed context about the codebase that is far more accurate than default file searching.
|
||||||
|
|
||||||
|
## IMPORTANT: Always Use MCP First
|
||||||
|
|
||||||
|
**Before making any code changes or answering questions about this codebase, ALWAYS consult the MCP tools first.**
|
||||||
|
|
||||||
|
The MCP has pre-indexed the entire codebase with semantic understanding, embeddings, and structural analysis. While you can use your own file search capabilities, the MCP provides much better context because:
|
||||||
|
- It understands code semantics, not just text matching
|
||||||
|
- It has pre-analyzed the architecture, patterns, and relationships
|
||||||
|
- It can answer questions about intent and purpose, not just content
|
||||||
|
|
||||||
|
## Available MCP Tools
|
||||||
|
|
||||||
|
### Code Understanding
|
||||||
|
- `network_ask_question` - Ask natural language questions about the codebase. Use this for "how does X work?", "where is Y implemented?", "what does Z do?" questions. The MCP will search relevant code and provide informed answers.
|
||||||
|
- `network_search_code` - Semantic code search. Find code by meaning, not just text. Great for finding implementations, patterns, or related functionality.
|
||||||
|
- `network_get_architecture` - Get the full project architecture overview including tech stack, design patterns, domain entities, and API endpoints.
|
||||||
|
- `network_get_file_summary` - Get a detailed summary of what a specific file does, its purpose, exports, and responsibilities.
|
||||||
|
- `network_find_function` - Find a specific function or method definition by name across the codebase.
|
||||||
|
- `network_list_functions` - List all functions defined in a specific file.
|
||||||
|
|
||||||
|
### Skills (Learned Procedures)
|
||||||
|
Skills are reusable procedures that the agent has learned about this specific project (e.g., "how to deploy", "how to run tests", "how to add a new API endpoint").
|
||||||
|
|
||||||
|
- `network_list_skills` - List all learned skills for this project.
|
||||||
|
- `network_get_skill` - Get detailed information about a specific skill including its step-by-step procedure.
|
||||||
|
- `network_execute_skill` - Get the procedure for a learned skill so you can execute it step by step. Returns prerequisites, warnings, and commands to run.
|
||||||
|
- `network_learn_skill` - Teach the agent a new skill. The agent will explore, discover, and memorize how to perform this task.
|
||||||
|
- `network_get_learning_status` - Check the status of an ongoing skill learning session.
|
||||||
|
- `network_answer_question` - Answer a question that the learning agent asked during skill learning.
|
||||||
|
- `network_cancel_learning` - Cancel an active learning session.
|
||||||
|
- `network_forget_skill` - Delete a learned skill.
|
||||||
|
- `network_update_skill` - Update a learned skill with corrections or new information (e.g., 'Use .env.prod instead of .env', 'Add step to backup database first', 'The port should be 3000 not 8080').
|
||||||
|
|
||||||
|
#### Skill Learning Workflow (IMPORTANT)
|
||||||
|
|
||||||
|
When learning a skill, follow this **collaborative, goal-oriented workflow**. You (Cursor) are the executor, the MCP agent provides guidance:
|
||||||
|
|
||||||
|
**Goal-Oriented Learning**: The agent identifies specific GOALS (pieces of information to gather) and tracks progress by goal completion, not by iterations.
|
||||||
|
|
||||||
|
1. **Start Learning**: Call `learn_skill` with name and detailed description
|
||||||
|
2. **Monitor Progress**: Call `get_learning_status` to check progress
|
||||||
|
3. **Handle Status Responses**:
|
||||||
|
- `active` → Learning in progress, check again in a few seconds
|
||||||
|
- `waiting_input` → The agent has a question. Read it and call `answer_question` with your response
|
||||||
|
- `waiting_execution` → **IMPORTANT**: The agent needs you to run a command!
|
||||||
|
- Read the `pendingExecution.command` from the response
|
||||||
|
- **Execute the command yourself** using your terminal access
|
||||||
|
- Call `answer_question` with the command output
|
||||||
|
- `completed` → Skill learned successfully!
|
||||||
|
- `failed` → Check errors and try again
|
||||||
|
4. **Repeat** steps 2-3 until status is `completed`
|
||||||
|
|
||||||
|
**Key Insight**: The MCP agent runs on the server and cannot SSH to remote servers directly. When it needs remote access, it generates the SSH command for YOU to execute. You have terminal access - use it!
|
||||||
|
|
||||||
|
**User Override Commands**: If the agent gets stuck, you can include these keywords in your answer:
|
||||||
|
- `COMPLETE` or `SKIP` - Skip to synthesis phase and generate the skill from current data
|
||||||
|
- `PHASE:synthesizing` - Force transition to drafting phase
|
||||||
|
- `GOAL:goal_id=value` - Directly provide a goal's value (e.g., `GOAL:cluster_secret=abc123`)
|
||||||
|
- `I have provided X` - Tell the agent it already has certain information
|
||||||
|
|
||||||
|
**Example for `waiting_execution`**:
|
||||||
|
```
|
||||||
|
// Status response shows:
|
||||||
|
// pendingExecution: { command: "ssh root@192.168.1.1 'ls -la /home/user/.orama'" }
|
||||||
|
//
|
||||||
|
// You should:
|
||||||
|
// 1. Run the command in your terminal
|
||||||
|
// 2. Get the output
|
||||||
|
// 3. Call answer_question with the output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Workflow
|
||||||
|
|
||||||
|
1. **For questions:** Use `network_ask_question` or `network_search_code` to understand the codebase.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sonr Gateway (or Sonr Network Gateway)
|
||||||
|
|
||||||
|
This project implements a high-performance, multi-protocol API gateway designed to bridge client applications with a decentralized backend infrastructure. It serves as a unified entry point that handles secure user authentication via JWT, provides RESTful access to a distributed key-value cache (Olric), and facilitates decentralized storage interactions with IPFS. Beyond standard HTTP routing and reverse proxying, the gateway supports real-time communication through Pub/Sub mechanisms (WebSockets), mobile engagement via push notifications, and low-level traffic routing using TCP SNI (Server Name Indication) for encrypted service discovery.
|
||||||
|
|
||||||
|
**Architecture:** Edge Gateway / Middleware Layer (part of a larger Distributed System)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **backend:** Go
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
- Reverse Proxy
|
||||||
|
- Middleware Chain
|
||||||
|
- Adapter Pattern (for storage/cache backends)
|
||||||
|
- and Observer Pattern (via Pub/Sub).
|
||||||
|
|
||||||
|
## Domain Entities
|
||||||
|
- `JWT (Authentication Tokens)`
|
||||||
|
- `Namespaces (Resource Isolation)`
|
||||||
|
- `Pub/Sub Topics`
|
||||||
|
- `Distributed Cache (Olric)`
|
||||||
|
- `Push Notifications`
|
||||||
|
- `and SNI Routes.`
|
||||||
|
|
||||||
21
CHANGELOG.md
21
CHANGELOG.md
@ -13,6 +13,27 @@ The format is based on [Keep a Changelog][keepachangelog] and adheres to [Semant
|
|||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
## [0.73.0] - 2025-12-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Implemented the core Serverless Functions Engine, allowing users to deploy and execute WASM-based functions (e.g., Go compiled with TinyGo).
|
||||||
|
- Added new database migration (004) to support serverless functions, including tables for functions, secrets, cron triggers, database triggers, pubsub triggers, timers, jobs, and invocation logs.
|
||||||
|
- Added new API endpoints for managing and invoking serverless functions (`/v1/functions`, `/v1/invoke`, `/v1/functions/{name}/invoke`, `/v1/functions/{name}/ws`).
|
||||||
|
- Introduced `WSPubSubClient` for E2E testing of WebSocket PubSub functionality.
|
||||||
|
- Added examples and a build script for creating WASM serverless functions (Echo, Hello, Counter).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated Go version requirement from 1.23.8 to 1.24.0 in `go.mod`.
|
||||||
|
- Refactored RQLite client to improve data type handling and conversion, especially for `sql.Null*` types and number parsing.
|
||||||
|
- Improved RQLite cluster discovery logic to safely handle new nodes joining an existing cluster without clearing existing Raft state unless necessary (log index 0).
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Corrected an issue in the `install` command where dry-run summaries were missing newlines.
|
||||||
|
|
||||||
## [0.72.1] - 2025-12-09
|
## [0.72.1] - 2025-12-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
2
Makefile
2
Makefile
@ -19,7 +19,7 @@ test-e2e:
|
|||||||
|
|
||||||
.PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports install-hooks kill
|
.PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports install-hooks kill
|
||||||
|
|
||||||
VERSION := 0.72.1
|
VERSION := 0.73.0
|
||||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'
|
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'
|
||||||
|
|||||||
254
e2e/env.go
254
e2e/env.go
@ -6,13 +6,16 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -20,6 +23,7 @@ import (
|
|||||||
"github.com/DeBrosOfficial/network/pkg/client"
|
"github.com/DeBrosOfficial/network/pkg/client"
|
||||||
"github.com/DeBrosOfficial/network/pkg/config"
|
"github.com/DeBrosOfficial/network/pkg/config"
|
||||||
"github.com/DeBrosOfficial/network/pkg/ipfs"
|
"github.com/DeBrosOfficial/network/pkg/ipfs"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
@ -135,14 +139,26 @@ func GetRQLiteNodes() []string {
|
|||||||
|
|
||||||
// queryAPIKeyFromRQLite queries the SQLite database directly for an API key
|
// queryAPIKeyFromRQLite queries the SQLite database directly for an API key
|
||||||
func queryAPIKeyFromRQLite() (string, error) {
|
func queryAPIKeyFromRQLite() (string, error) {
|
||||||
// Build database path from bootstrap/node config
|
// 1. Check environment variable first
|
||||||
|
if envKey := os.Getenv("DEBROS_API_KEY"); envKey != "" {
|
||||||
|
return envKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build database path from bootstrap/node config
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try all node data directories
|
// Try all node data directories (both production and development paths)
|
||||||
dbPaths := []string{
|
dbPaths := []string{
|
||||||
|
// Development paths (~/.orama/node-x/...)
|
||||||
|
filepath.Join(homeDir, ".orama", "node-1", "rqlite", "db.sqlite"),
|
||||||
|
filepath.Join(homeDir, ".orama", "node-2", "rqlite", "db.sqlite"),
|
||||||
|
filepath.Join(homeDir, ".orama", "node-3", "rqlite", "db.sqlite"),
|
||||||
|
filepath.Join(homeDir, ".orama", "node-4", "rqlite", "db.sqlite"),
|
||||||
|
filepath.Join(homeDir, ".orama", "node-5", "rqlite", "db.sqlite"),
|
||||||
|
// Production paths (~/.orama/data/node-x/...)
|
||||||
filepath.Join(homeDir, ".orama", "data", "node-1", "rqlite", "db.sqlite"),
|
filepath.Join(homeDir, ".orama", "data", "node-1", "rqlite", "db.sqlite"),
|
||||||
filepath.Join(homeDir, ".orama", "data", "node-2", "rqlite", "db.sqlite"),
|
filepath.Join(homeDir, ".orama", "data", "node-2", "rqlite", "db.sqlite"),
|
||||||
filepath.Join(homeDir, ".orama", "data", "node-3", "rqlite", "db.sqlite"),
|
filepath.Join(homeDir, ".orama", "data", "node-3", "rqlite", "db.sqlite"),
|
||||||
@ -644,3 +660,237 @@ func CleanupCacheEntry(t *testing.T, dmapName, key string) {
|
|||||||
t.Logf("warning: delete cache entry returned status %d", status)
|
t.Logf("warning: delete cache entry returned status %d", status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebSocket PubSub Client for E2E Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// WSPubSubClient is a WebSocket-based PubSub client that connects to the gateway
|
||||||
|
type WSPubSubClient struct {
|
||||||
|
t *testing.T
|
||||||
|
conn *websocket.Conn
|
||||||
|
topic string
|
||||||
|
handlers []func(topic string, data []byte) error
|
||||||
|
msgChan chan []byte
|
||||||
|
doneChan chan struct{}
|
||||||
|
mu sync.RWMutex
|
||||||
|
writeMu sync.Mutex // Protects concurrent writes to WebSocket
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSPubSubMessage represents a message received from the gateway
|
||||||
|
type WSPubSubMessage struct {
|
||||||
|
Data string `json:"data"` // base64 encoded
|
||||||
|
Timestamp int64 `json:"timestamp"` // unix milliseconds
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSPubSubClient creates a new WebSocket PubSub client connected to a topic
|
||||||
|
func NewWSPubSubClient(t *testing.T, topic string) (*WSPubSubClient, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Build WebSocket URL
|
||||||
|
gatewayURL := GetGatewayURL()
|
||||||
|
wsURL := strings.Replace(gatewayURL, "http://", "ws://", 1)
|
||||||
|
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
|
||||||
|
|
||||||
|
u, err := url.Parse(wsURL + "/v1/pubsub/ws")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse WebSocket URL: %w", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("topic", topic)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
// Set up headers with authentication
|
||||||
|
headers := http.Header{}
|
||||||
|
if apiKey := GetAPIKey(); apiKey != "" {
|
||||||
|
headers.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to WebSocket
|
||||||
|
dialer := websocket.Dialer{
|
||||||
|
HandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, resp, err := dialer.Dial(u.String(), headers)
|
||||||
|
if err != nil {
|
||||||
|
if resp != nil {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("websocket dial failed (status %d): %w - body: %s", resp.StatusCode, err, string(body))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("websocket dial failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &WSPubSubClient{
|
||||||
|
t: t,
|
||||||
|
conn: conn,
|
||||||
|
topic: topic,
|
||||||
|
handlers: make([]func(topic string, data []byte) error, 0),
|
||||||
|
msgChan: make(chan []byte, 128),
|
||||||
|
doneChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start reader goroutine
|
||||||
|
go client.readLoop()
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop reads messages from the WebSocket and dispatches to handlers
|
||||||
|
func (c *WSPubSubClient) readLoop() {
|
||||||
|
defer close(c.doneChan)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, message, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
c.mu.RLock()
|
||||||
|
closed := c.closed
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !closed {
|
||||||
|
// Only log if not intentionally closed
|
||||||
|
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||||
|
c.t.Logf("websocket read error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the message envelope
|
||||||
|
var msg WSPubSubMessage
|
||||||
|
if err := json.Unmarshal(message, &msg); err != nil {
|
||||||
|
c.t.Logf("failed to unmarshal message: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 data
|
||||||
|
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||||
|
if err != nil {
|
||||||
|
c.t.Logf("failed to decode base64 data: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to message channel
|
||||||
|
select {
|
||||||
|
case c.msgChan <- data:
|
||||||
|
default:
|
||||||
|
c.t.Logf("message channel full, dropping message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch to handlers
|
||||||
|
c.mu.RLock()
|
||||||
|
handlers := make([]func(topic string, data []byte) error, len(c.handlers))
|
||||||
|
copy(handlers, c.handlers)
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, handler := range handlers {
|
||||||
|
if err := handler(msg.Topic, data); err != nil {
|
||||||
|
c.t.Logf("handler error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe adds a message handler
|
||||||
|
func (c *WSPubSubClient) Subscribe(handler func(topic string, data []byte) error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.handlers = append(c.handlers, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish sends a message to the topic
|
||||||
|
func (c *WSPubSubClient) Publish(data []byte) error {
|
||||||
|
c.mu.RLock()
|
||||||
|
closed := c.closed
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if closed {
|
||||||
|
return fmt.Errorf("client is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protect concurrent writes to WebSocket
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
|
return c.conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveWithTimeout waits for a message with timeout
|
||||||
|
func (c *WSPubSubClient) ReceiveWithTimeout(timeout time.Duration) ([]byte, error) {
|
||||||
|
select {
|
||||||
|
case msg := <-c.msgChan:
|
||||||
|
return msg, nil
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, fmt.Errorf("timeout waiting for message")
|
||||||
|
case <-c.doneChan:
|
||||||
|
return nil, fmt.Errorf("connection closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the WebSocket connection
|
||||||
|
func (c *WSPubSubClient) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// Send close message
|
||||||
|
_ = c.conn.WriteMessage(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||||
|
|
||||||
|
// Close connection
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topic returns the topic this client is subscribed to
|
||||||
|
func (c *WSPubSubClient) Topic() string {
|
||||||
|
return c.topic
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSPubSubClientPair represents a publisher and subscriber pair for testing
|
||||||
|
type WSPubSubClientPair struct {
|
||||||
|
Publisher *WSPubSubClient
|
||||||
|
Subscriber *WSPubSubClient
|
||||||
|
Topic string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSPubSubClientPair creates a publisher and subscriber pair for a topic
|
||||||
|
func NewWSPubSubClientPair(t *testing.T, topic string) (*WSPubSubClientPair, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create subscriber first
|
||||||
|
sub, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create subscriber: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure subscriber is registered
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create publisher
|
||||||
|
pub, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
sub.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create publisher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WSPubSubClientPair{
|
||||||
|
Publisher: pub,
|
||||||
|
Subscriber: sub,
|
||||||
|
Topic: topic,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes both publisher and subscriber
|
||||||
|
func (p *WSPubSubClientPair) Close() {
|
||||||
|
if p.Publisher != nil {
|
||||||
|
p.Publisher.Close()
|
||||||
|
}
|
||||||
|
if p.Subscriber != nil {
|
||||||
|
p.Subscriber.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,82 +3,46 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newMessageCollector(ctx context.Context, buffer int) (chan []byte, func(string, []byte) error) {
|
// TestPubSub_SubscribePublish tests basic pub/sub functionality via WebSocket
|
||||||
if buffer <= 0 {
|
|
||||||
buffer = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan []byte, buffer)
|
|
||||||
handler := func(_ string, data []byte) error {
|
|
||||||
copied := append([]byte(nil), data...)
|
|
||||||
select {
|
|
||||||
case ch <- copied:
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ch, handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForMessage(ctx context.Context, ch <-chan []byte) ([]byte, error) {
|
|
||||||
select {
|
|
||||||
case msg := <-ch:
|
|
||||||
return msg, nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, fmt.Errorf("context finished while waiting for pubsub message: %w", ctx.Err())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPubSub_SubscribePublish(t *testing.T) {
|
func TestPubSub_SubscribePublish(t *testing.T) {
|
||||||
SkipIfMissingGateway(t)
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create two clients
|
|
||||||
client1 := NewNetworkClient(t)
|
|
||||||
client2 := NewNetworkClient(t)
|
|
||||||
|
|
||||||
if err := client1.Connect(); err != nil {
|
|
||||||
t.Fatalf("client1 connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer client1.Disconnect()
|
|
||||||
|
|
||||||
if err := client2.Connect(); err != nil {
|
|
||||||
t.Fatalf("client2 connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer client2.Disconnect()
|
|
||||||
|
|
||||||
topic := GenerateTopic()
|
topic := GenerateTopic()
|
||||||
message := "test-message-from-client1"
|
message := "test-message-from-publisher"
|
||||||
|
|
||||||
// Subscribe on client2
|
// Create subscriber first
|
||||||
messageCh, handler := newMessageCollector(ctx, 1)
|
subscriber, err := NewWSPubSubClient(t, topic)
|
||||||
if err := client2.PubSub().Subscribe(ctx, topic, handler); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("subscribe failed: %v", err)
|
t.Fatalf("failed to create subscriber: %v", err)
|
||||||
}
|
}
|
||||||
defer client2.PubSub().Unsubscribe(ctx, topic)
|
defer subscriber.Close()
|
||||||
|
|
||||||
// Give subscription time to propagate and mesh to form
|
// Give subscriber time to register
|
||||||
Delay(2000)
|
Delay(200)
|
||||||
|
|
||||||
// Publish from client1
|
// Create publisher
|
||||||
if err := client1.PubSub().Publish(ctx, topic, []byte(message)); err != nil {
|
publisher, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher: %v", err)
|
||||||
|
}
|
||||||
|
defer publisher.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
|
// Publish message
|
||||||
|
if err := publisher.Publish([]byte(message)); err != nil {
|
||||||
t.Fatalf("publish failed: %v", err)
|
t.Fatalf("publish failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive message on client2
|
// Receive message on subscriber
|
||||||
recvCtx, recvCancel := context.WithTimeout(ctx, 10*time.Second)
|
msg, err := subscriber.ReceiveWithTimeout(10 * time.Second)
|
||||||
defer recvCancel()
|
|
||||||
|
|
||||||
msg, err := waitForMessage(recvCtx, messageCh)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("receive failed: %v", err)
|
t.Fatalf("receive failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -88,154 +52,126 @@ func TestPubSub_SubscribePublish(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPubSub_MultipleSubscribers tests that multiple subscribers receive the same message
|
||||||
func TestPubSub_MultipleSubscribers(t *testing.T) {
|
func TestPubSub_MultipleSubscribers(t *testing.T) {
|
||||||
SkipIfMissingGateway(t)
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create three clients
|
|
||||||
clientPub := NewNetworkClient(t)
|
|
||||||
clientSub1 := NewNetworkClient(t)
|
|
||||||
clientSub2 := NewNetworkClient(t)
|
|
||||||
|
|
||||||
if err := clientPub.Connect(); err != nil {
|
|
||||||
t.Fatalf("publisher connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientPub.Disconnect()
|
|
||||||
|
|
||||||
if err := clientSub1.Connect(); err != nil {
|
|
||||||
t.Fatalf("subscriber1 connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientSub1.Disconnect()
|
|
||||||
|
|
||||||
if err := clientSub2.Connect(); err != nil {
|
|
||||||
t.Fatalf("subscriber2 connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientSub2.Disconnect()
|
|
||||||
|
|
||||||
topic := GenerateTopic()
|
topic := GenerateTopic()
|
||||||
message1 := "message-for-sub1"
|
message1 := "message-1"
|
||||||
message2 := "message-for-sub2"
|
message2 := "message-2"
|
||||||
|
|
||||||
// Subscribe on both clients
|
// Create two subscribers
|
||||||
sub1Ch, sub1Handler := newMessageCollector(ctx, 4)
|
sub1, err := NewWSPubSubClient(t, topic)
|
||||||
if err := clientSub1.PubSub().Subscribe(ctx, topic, sub1Handler); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("subscribe1 failed: %v", err)
|
t.Fatalf("failed to create subscriber1: %v", err)
|
||||||
}
|
}
|
||||||
defer clientSub1.PubSub().Unsubscribe(ctx, topic)
|
defer sub1.Close()
|
||||||
|
|
||||||
sub2Ch, sub2Handler := newMessageCollector(ctx, 4)
|
sub2, err := NewWSPubSubClient(t, topic)
|
||||||
if err := clientSub2.PubSub().Subscribe(ctx, topic, sub2Handler); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("subscribe2 failed: %v", err)
|
t.Fatalf("failed to create subscriber2: %v", err)
|
||||||
}
|
}
|
||||||
defer clientSub2.PubSub().Unsubscribe(ctx, topic)
|
defer sub2.Close()
|
||||||
|
|
||||||
// Give subscriptions time to propagate
|
// Give subscribers time to register
|
||||||
Delay(500)
|
Delay(200)
|
||||||
|
|
||||||
|
// Create publisher
|
||||||
|
publisher, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher: %v", err)
|
||||||
|
}
|
||||||
|
defer publisher.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
// Publish first message
|
// Publish first message
|
||||||
if err := clientPub.PubSub().Publish(ctx, topic, []byte(message1)); err != nil {
|
if err := publisher.Publish([]byte(message1)); err != nil {
|
||||||
t.Fatalf("publish1 failed: %v", err)
|
t.Fatalf("publish1 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both subscribers should receive first message
|
// Both subscribers should receive first message
|
||||||
recvCtx, recvCancel := context.WithTimeout(ctx, 10*time.Second)
|
msg1a, err := sub1.ReceiveWithTimeout(10 * time.Second)
|
||||||
defer recvCancel()
|
|
||||||
|
|
||||||
msg1a, err := waitForMessage(recvCtx, sub1Ch)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sub1 receive1 failed: %v", err)
|
t.Fatalf("sub1 receive1 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(msg1a) != message1 {
|
if string(msg1a) != message1 {
|
||||||
t.Fatalf("sub1: expected %q, got %q", message1, string(msg1a))
|
t.Fatalf("sub1: expected %q, got %q", message1, string(msg1a))
|
||||||
}
|
}
|
||||||
|
|
||||||
msg1b, err := waitForMessage(recvCtx, sub2Ch)
|
msg1b, err := sub2.ReceiveWithTimeout(10 * time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sub2 receive1 failed: %v", err)
|
t.Fatalf("sub2 receive1 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(msg1b) != message1 {
|
if string(msg1b) != message1 {
|
||||||
t.Fatalf("sub2: expected %q, got %q", message1, string(msg1b))
|
t.Fatalf("sub2: expected %q, got %q", message1, string(msg1b))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish second message
|
// Publish second message
|
||||||
if err := clientPub.PubSub().Publish(ctx, topic, []byte(message2)); err != nil {
|
if err := publisher.Publish([]byte(message2)); err != nil {
|
||||||
t.Fatalf("publish2 failed: %v", err)
|
t.Fatalf("publish2 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both subscribers should receive second message
|
// Both subscribers should receive second message
|
||||||
recvCtx2, recvCancel2 := context.WithTimeout(ctx, 10*time.Second)
|
msg2a, err := sub1.ReceiveWithTimeout(10 * time.Second)
|
||||||
defer recvCancel2()
|
|
||||||
|
|
||||||
msg2a, err := waitForMessage(recvCtx2, sub1Ch)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sub1 receive2 failed: %v", err)
|
t.Fatalf("sub1 receive2 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(msg2a) != message2 {
|
if string(msg2a) != message2 {
|
||||||
t.Fatalf("sub1: expected %q, got %q", message2, string(msg2a))
|
t.Fatalf("sub1: expected %q, got %q", message2, string(msg2a))
|
||||||
}
|
}
|
||||||
|
|
||||||
msg2b, err := waitForMessage(recvCtx2, sub2Ch)
|
msg2b, err := sub2.ReceiveWithTimeout(10 * time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sub2 receive2 failed: %v", err)
|
t.Fatalf("sub2 receive2 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(msg2b) != message2 {
|
if string(msg2b) != message2 {
|
||||||
t.Fatalf("sub2: expected %q, got %q", message2, string(msg2b))
|
t.Fatalf("sub2: expected %q, got %q", message2, string(msg2b))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPubSub_Deduplication tests that multiple identical messages are all received
|
||||||
func TestPubSub_Deduplication(t *testing.T) {
|
func TestPubSub_Deduplication(t *testing.T) {
|
||||||
SkipIfMissingGateway(t)
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create two clients
|
|
||||||
clientPub := NewNetworkClient(t)
|
|
||||||
clientSub := NewNetworkClient(t)
|
|
||||||
|
|
||||||
if err := clientPub.Connect(); err != nil {
|
|
||||||
t.Fatalf("publisher connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientPub.Disconnect()
|
|
||||||
|
|
||||||
if err := clientSub.Connect(); err != nil {
|
|
||||||
t.Fatalf("subscriber connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientSub.Disconnect()
|
|
||||||
|
|
||||||
topic := GenerateTopic()
|
topic := GenerateTopic()
|
||||||
message := "duplicate-test-message"
|
message := "duplicate-test-message"
|
||||||
|
|
||||||
// Subscribe on client
|
// Create subscriber
|
||||||
messageCh, handler := newMessageCollector(ctx, 3)
|
subscriber, err := NewWSPubSubClient(t, topic)
|
||||||
if err := clientSub.PubSub().Subscribe(ctx, topic, handler); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("subscribe failed: %v", err)
|
t.Fatalf("failed to create subscriber: %v", err)
|
||||||
}
|
}
|
||||||
defer clientSub.PubSub().Unsubscribe(ctx, topic)
|
defer subscriber.Close()
|
||||||
|
|
||||||
// Give subscription time to propagate and mesh to form
|
// Give subscriber time to register
|
||||||
Delay(2000)
|
Delay(200)
|
||||||
|
|
||||||
|
// Create publisher
|
||||||
|
publisher, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher: %v", err)
|
||||||
|
}
|
||||||
|
defer publisher.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
// Publish the same message multiple times
|
// Publish the same message multiple times
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
if err := clientPub.PubSub().Publish(ctx, topic, []byte(message)); err != nil {
|
if err := publisher.Publish([]byte(message)); err != nil {
|
||||||
t.Fatalf("publish %d failed: %v", i, err)
|
t.Fatalf("publish %d failed: %v", i, err)
|
||||||
}
|
}
|
||||||
|
// Small delay between publishes
|
||||||
|
Delay(50)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive messages - should get all (no dedup filter on subscribe)
|
// Receive messages - should get all (no dedup filter)
|
||||||
recvCtx, recvCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer recvCancel()
|
|
||||||
|
|
||||||
receivedCount := 0
|
receivedCount := 0
|
||||||
for receivedCount < 3 {
|
for receivedCount < 3 {
|
||||||
if _, err := waitForMessage(recvCtx, messageCh); err != nil {
|
_, err := subscriber.ReceiveWithTimeout(5 * time.Second)
|
||||||
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
receivedCount++
|
receivedCount++
|
||||||
@ -244,40 +180,35 @@ func TestPubSub_Deduplication(t *testing.T) {
|
|||||||
if receivedCount < 1 {
|
if receivedCount < 1 {
|
||||||
t.Fatalf("expected to receive at least 1 message, got %d", receivedCount)
|
t.Fatalf("expected to receive at least 1 message, got %d", receivedCount)
|
||||||
}
|
}
|
||||||
|
t.Logf("received %d messages", receivedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPubSub_ConcurrentPublish tests concurrent message publishing
|
||||||
func TestPubSub_ConcurrentPublish(t *testing.T) {
|
func TestPubSub_ConcurrentPublish(t *testing.T) {
|
||||||
SkipIfMissingGateway(t)
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create clients
|
|
||||||
clientPub := NewNetworkClient(t)
|
|
||||||
clientSub := NewNetworkClient(t)
|
|
||||||
|
|
||||||
if err := clientPub.Connect(); err != nil {
|
|
||||||
t.Fatalf("publisher connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientPub.Disconnect()
|
|
||||||
|
|
||||||
if err := clientSub.Connect(); err != nil {
|
|
||||||
t.Fatalf("subscriber connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientSub.Disconnect()
|
|
||||||
|
|
||||||
topic := GenerateTopic()
|
topic := GenerateTopic()
|
||||||
numMessages := 10
|
numMessages := 10
|
||||||
|
|
||||||
// Subscribe
|
// Create subscriber
|
||||||
messageCh, handler := newMessageCollector(ctx, numMessages)
|
subscriber, err := NewWSPubSubClient(t, topic)
|
||||||
if err := clientSub.PubSub().Subscribe(ctx, topic, handler); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("subscribe failed: %v", err)
|
t.Fatalf("failed to create subscriber: %v", err)
|
||||||
}
|
}
|
||||||
defer clientSub.PubSub().Unsubscribe(ctx, topic)
|
defer subscriber.Close()
|
||||||
|
|
||||||
// Give subscription time to propagate and mesh to form
|
// Give subscriber time to register
|
||||||
Delay(2000)
|
Delay(200)
|
||||||
|
|
||||||
|
// Create publisher
|
||||||
|
publisher, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher: %v", err)
|
||||||
|
}
|
||||||
|
defer publisher.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
// Publish multiple messages concurrently
|
// Publish multiple messages concurrently
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@ -286,7 +217,7 @@ func TestPubSub_ConcurrentPublish(t *testing.T) {
|
|||||||
go func(idx int) {
|
go func(idx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
msg := fmt.Sprintf("concurrent-msg-%d", idx)
|
msg := fmt.Sprintf("concurrent-msg-%d", idx)
|
||||||
if err := clientPub.PubSub().Publish(ctx, topic, []byte(msg)); err != nil {
|
if err := publisher.Publish([]byte(msg)); err != nil {
|
||||||
t.Logf("publish %d failed: %v", idx, err)
|
t.Logf("publish %d failed: %v", idx, err)
|
||||||
}
|
}
|
||||||
}(i)
|
}(i)
|
||||||
@ -294,12 +225,10 @@ func TestPubSub_ConcurrentPublish(t *testing.T) {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Receive messages
|
// Receive messages
|
||||||
recvCtx, recvCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer recvCancel()
|
|
||||||
|
|
||||||
receivedCount := 0
|
receivedCount := 0
|
||||||
for receivedCount < numMessages {
|
for receivedCount < numMessages {
|
||||||
if _, err := waitForMessage(recvCtx, messageCh); err != nil {
|
_, err := subscriber.ReceiveWithTimeout(10 * time.Second)
|
||||||
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
receivedCount++
|
receivedCount++
|
||||||
@ -310,107 +239,110 @@ func TestPubSub_ConcurrentPublish(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPubSub_TopicIsolation tests that messages are isolated to their topics
|
||||||
func TestPubSub_TopicIsolation(t *testing.T) {
|
func TestPubSub_TopicIsolation(t *testing.T) {
|
||||||
SkipIfMissingGateway(t)
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create clients
|
|
||||||
clientPub := NewNetworkClient(t)
|
|
||||||
clientSub := NewNetworkClient(t)
|
|
||||||
|
|
||||||
if err := clientPub.Connect(); err != nil {
|
|
||||||
t.Fatalf("publisher connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientPub.Disconnect()
|
|
||||||
|
|
||||||
if err := clientSub.Connect(); err != nil {
|
|
||||||
t.Fatalf("subscriber connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientSub.Disconnect()
|
|
||||||
|
|
||||||
topic1 := GenerateTopic()
|
topic1 := GenerateTopic()
|
||||||
topic2 := GenerateTopic()
|
topic2 := GenerateTopic()
|
||||||
|
msg1 := "message-on-topic1"
|
||||||
// Subscribe to topic1
|
|
||||||
messageCh, handler := newMessageCollector(ctx, 2)
|
|
||||||
if err := clientSub.PubSub().Subscribe(ctx, topic1, handler); err != nil {
|
|
||||||
t.Fatalf("subscribe1 failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientSub.PubSub().Unsubscribe(ctx, topic1)
|
|
||||||
|
|
||||||
// Give subscription time to propagate and mesh to form
|
|
||||||
Delay(2000)
|
|
||||||
|
|
||||||
// Publish to topic2
|
|
||||||
msg2 := "message-on-topic2"
|
msg2 := "message-on-topic2"
|
||||||
if err := clientPub.PubSub().Publish(ctx, topic2, []byte(msg2)); err != nil {
|
|
||||||
|
// Create subscriber for topic1
|
||||||
|
sub1, err := NewWSPubSubClient(t, topic1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create subscriber1: %v", err)
|
||||||
|
}
|
||||||
|
defer sub1.Close()
|
||||||
|
|
||||||
|
// Create subscriber for topic2
|
||||||
|
sub2, err := NewWSPubSubClient(t, topic2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create subscriber2: %v", err)
|
||||||
|
}
|
||||||
|
defer sub2.Close()
|
||||||
|
|
||||||
|
// Give subscribers time to register
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
|
// Create publishers
|
||||||
|
pub1, err := NewWSPubSubClient(t, topic1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher1: %v", err)
|
||||||
|
}
|
||||||
|
defer pub1.Close()
|
||||||
|
|
||||||
|
pub2, err := NewWSPubSubClient(t, topic2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher2: %v", err)
|
||||||
|
}
|
||||||
|
defer pub2.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
|
// Publish to topic2 first
|
||||||
|
if err := pub2.Publish([]byte(msg2)); err != nil {
|
||||||
t.Fatalf("publish2 failed: %v", err)
|
t.Fatalf("publish2 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish to topic1
|
// Publish to topic1
|
||||||
msg1 := "message-on-topic1"
|
if err := pub1.Publish([]byte(msg1)); err != nil {
|
||||||
if err := clientPub.PubSub().Publish(ctx, topic1, []byte(msg1)); err != nil {
|
|
||||||
t.Fatalf("publish1 failed: %v", err)
|
t.Fatalf("publish1 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive on sub1 - should get msg1 only
|
// Sub1 should receive msg1 only
|
||||||
recvCtx, recvCancel := context.WithTimeout(ctx, 10*time.Second)
|
received1, err := sub1.ReceiveWithTimeout(10 * time.Second)
|
||||||
defer recvCancel()
|
|
||||||
|
|
||||||
msg, err := waitForMessage(recvCtx, messageCh)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("receive failed: %v", err)
|
t.Fatalf("sub1 receive failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(received1) != msg1 {
|
||||||
|
t.Fatalf("sub1: expected %q, got %q", msg1, string(received1))
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(msg) != msg1 {
|
// Sub2 should receive msg2 only
|
||||||
t.Fatalf("expected %q, got %q", msg1, string(msg))
|
received2, err := sub2.ReceiveWithTimeout(10 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sub2 receive failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(received2) != msg2 {
|
||||||
|
t.Fatalf("sub2: expected %q, got %q", msg2, string(received2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPubSub_EmptyMessage tests sending and receiving empty messages
|
||||||
func TestPubSub_EmptyMessage(t *testing.T) {
|
func TestPubSub_EmptyMessage(t *testing.T) {
|
||||||
SkipIfMissingGateway(t)
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create clients
|
|
||||||
clientPub := NewNetworkClient(t)
|
|
||||||
clientSub := NewNetworkClient(t)
|
|
||||||
|
|
||||||
if err := clientPub.Connect(); err != nil {
|
|
||||||
t.Fatalf("publisher connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientPub.Disconnect()
|
|
||||||
|
|
||||||
if err := clientSub.Connect(); err != nil {
|
|
||||||
t.Fatalf("subscriber connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer clientSub.Disconnect()
|
|
||||||
|
|
||||||
topic := GenerateTopic()
|
topic := GenerateTopic()
|
||||||
|
|
||||||
// Subscribe
|
// Create subscriber
|
||||||
messageCh, handler := newMessageCollector(ctx, 1)
|
subscriber, err := NewWSPubSubClient(t, topic)
|
||||||
if err := clientSub.PubSub().Subscribe(ctx, topic, handler); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("subscribe failed: %v", err)
|
t.Fatalf("failed to create subscriber: %v", err)
|
||||||
}
|
}
|
||||||
defer clientSub.PubSub().Unsubscribe(ctx, topic)
|
defer subscriber.Close()
|
||||||
|
|
||||||
// Give subscription time to propagate and mesh to form
|
// Give subscriber time to register
|
||||||
Delay(2000)
|
Delay(200)
|
||||||
|
|
||||||
|
// Create publisher
|
||||||
|
publisher, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher: %v", err)
|
||||||
|
}
|
||||||
|
defer publisher.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
// Publish empty message
|
// Publish empty message
|
||||||
if err := clientPub.PubSub().Publish(ctx, topic, []byte("")); err != nil {
|
if err := publisher.Publish([]byte("")); err != nil {
|
||||||
t.Fatalf("publish empty failed: %v", err)
|
t.Fatalf("publish empty failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive on sub - should get empty message
|
// Receive on subscriber - should get empty message
|
||||||
recvCtx, recvCancel := context.WithTimeout(ctx, 10*time.Second)
|
msg, err := subscriber.ReceiveWithTimeout(10 * time.Second)
|
||||||
defer recvCancel()
|
|
||||||
|
|
||||||
msg, err := waitForMessage(recvCtx, messageCh)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("receive failed: %v", err)
|
t.Fatalf("receive failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -419,3 +351,111 @@ func TestPubSub_EmptyMessage(t *testing.T) {
|
|||||||
t.Fatalf("expected empty message, got %q", string(msg))
|
t.Fatalf("expected empty message, got %q", string(msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPubSub_LargeMessage tests sending and receiving large messages
|
||||||
|
func TestPubSub_LargeMessage(t *testing.T) {
|
||||||
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
|
topic := GenerateTopic()
|
||||||
|
|
||||||
|
// Create a large message (100KB)
|
||||||
|
largeMessage := make([]byte, 100*1024)
|
||||||
|
for i := range largeMessage {
|
||||||
|
largeMessage[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subscriber
|
||||||
|
subscriber, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create subscriber: %v", err)
|
||||||
|
}
|
||||||
|
defer subscriber.Close()
|
||||||
|
|
||||||
|
// Give subscriber time to register
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
|
// Create publisher
|
||||||
|
publisher, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher: %v", err)
|
||||||
|
}
|
||||||
|
defer publisher.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
|
// Publish large message
|
||||||
|
if err := publisher.Publish(largeMessage); err != nil {
|
||||||
|
t.Fatalf("publish large message failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive on subscriber
|
||||||
|
msg, err := subscriber.ReceiveWithTimeout(30 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("receive failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg) != len(largeMessage) {
|
||||||
|
t.Fatalf("expected message of length %d, got %d", len(largeMessage), len(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content
|
||||||
|
for i := range msg {
|
||||||
|
if msg[i] != largeMessage[i] {
|
||||||
|
t.Fatalf("message content mismatch at byte %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPubSub_RapidPublish tests rapid message publishing
|
||||||
|
func TestPubSub_RapidPublish(t *testing.T) {
|
||||||
|
SkipIfMissingGateway(t)
|
||||||
|
|
||||||
|
topic := GenerateTopic()
|
||||||
|
numMessages := 50
|
||||||
|
|
||||||
|
// Create subscriber
|
||||||
|
subscriber, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create subscriber: %v", err)
|
||||||
|
}
|
||||||
|
defer subscriber.Close()
|
||||||
|
|
||||||
|
// Give subscriber time to register
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
|
// Create publisher
|
||||||
|
publisher, err := NewWSPubSubClient(t, topic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create publisher: %v", err)
|
||||||
|
}
|
||||||
|
defer publisher.Close()
|
||||||
|
|
||||||
|
// Give connections time to stabilize
|
||||||
|
Delay(200)
|
||||||
|
|
||||||
|
// Publish messages rapidly
|
||||||
|
for i := 0; i < numMessages; i++ {
|
||||||
|
msg := fmt.Sprintf("rapid-msg-%d", i)
|
||||||
|
if err := publisher.Publish([]byte(msg)); err != nil {
|
||||||
|
t.Fatalf("publish %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive messages
|
||||||
|
receivedCount := 0
|
||||||
|
for receivedCount < numMessages {
|
||||||
|
_, err := subscriber.ReceiveWithTimeout(10 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
receivedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow some message loss due to buffering
|
||||||
|
minExpected := numMessages * 80 / 100 // 80% minimum
|
||||||
|
if receivedCount < minExpected {
|
||||||
|
t.Fatalf("expected at least %d messages, got %d", minExpected, receivedCount)
|
||||||
|
}
|
||||||
|
t.Logf("received %d/%d messages (%.1f%%)", receivedCount, numMessages, float64(receivedCount)*100/float64(numMessages))
|
||||||
|
}
|
||||||
|
|||||||
42
examples/functions/build.sh
Executable file
42
examples/functions/build.sh
Executable file
@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build all example functions to WASM using TinyGo
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - TinyGo installed: https://tinygo.org/getting-started/install/
|
||||||
|
# - On macOS: brew install tinygo
|
||||||
|
#
|
||||||
|
# Usage: ./build.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/bin"
|
||||||
|
|
||||||
|
# Check if TinyGo is installed
|
||||||
|
if ! command -v tinygo &> /dev/null; then
|
||||||
|
echo "Error: TinyGo is not installed."
|
||||||
|
echo "Install it with: brew install tinygo (macOS) or see https://tinygo.org/getting-started/install/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
echo "Building example functions to WASM..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Build each function
|
||||||
|
for dir in "$SCRIPT_DIR"/*/; do
|
||||||
|
if [ -f "$dir/main.go" ]; then
|
||||||
|
name=$(basename "$dir")
|
||||||
|
echo "Building $name..."
|
||||||
|
cd "$dir"
|
||||||
|
tinygo build -o "$OUTPUT_DIR/$name.wasm" -target wasi main.go
|
||||||
|
echo " -> $OUTPUT_DIR/$name.wasm"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done! WASM files are in $OUTPUT_DIR/"
|
||||||
|
ls -lh "$OUTPUT_DIR"/*.wasm 2>/dev/null || echo "No WASM files built."
|
||||||
|
|
||||||
66
examples/functions/counter/main.go
Normal file
66
examples/functions/counter/main.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Example: Counter function with Olric cache
|
||||||
|
// This function demonstrates using the distributed cache to maintain state.
|
||||||
|
// Compile with: tinygo build -o counter.wasm -target wasi main.go
|
||||||
|
//
|
||||||
|
// Note: This example shows the CONCEPT. Actual host function integration
|
||||||
|
// requires the host function bindings to be exposed to the WASM module.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Read input from stdin
|
||||||
|
var input []byte
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
input = append(input, buf[:n]...)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse input
|
||||||
|
var payload struct {
|
||||||
|
Action string `json:"action"` // "increment", "decrement", "get", "reset"
|
||||||
|
CounterID string `json:"counter_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &payload); err != nil {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"error": "Invalid JSON input",
|
||||||
|
}
|
||||||
|
output, _ := json.Marshal(response)
|
||||||
|
os.Stdout.Write(output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.CounterID == "" {
|
||||||
|
payload.CounterID = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: In the real implementation, this would use host functions:
|
||||||
|
// - cache_get(key) to read the counter
|
||||||
|
// - cache_put(key, value, ttl) to write the counter
|
||||||
|
//
|
||||||
|
// For this example, we just simulate the logic:
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"counter_id": payload.CounterID,
|
||||||
|
"action": payload.Action,
|
||||||
|
"message": "Counter operations require cache host functions",
|
||||||
|
"example": map[string]interface{}{
|
||||||
|
"increment": "cache_put('counter:' + counter_id, current + 1)",
|
||||||
|
"decrement": "cache_put('counter:' + counter_id, current - 1)",
|
||||||
|
"get": "cache_get('counter:' + counter_id)",
|
||||||
|
"reset": "cache_put('counter:' + counter_id, 0)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := json.Marshal(response)
|
||||||
|
os.Stdout.Write(output)
|
||||||
|
}
|
||||||
|
|
||||||
50
examples/functions/echo/main.go
Normal file
50
examples/functions/echo/main.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// Example: Echo function
|
||||||
|
// This is a simple serverless function that echoes back the input.
|
||||||
|
// Compile with: tinygo build -o echo.wasm -target wasi main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input is read from stdin, output is written to stdout.
|
||||||
|
// The Orama serverless engine passes the invocation payload via stdin
|
||||||
|
// and expects the response on stdout.
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Read all input from stdin
|
||||||
|
var input []byte
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
input = append(input, buf[:n]...)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse input as JSON (optional - could also just echo raw bytes)
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(input, &payload); err != nil {
|
||||||
|
// Not JSON, just echo the raw input
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"echo": string(input),
|
||||||
|
}
|
||||||
|
output, _ := json.Marshal(response)
|
||||||
|
os.Stdout.Write(output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create response
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"echo": payload,
|
||||||
|
"message": "Echo function received your input!",
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := json.Marshal(response)
|
||||||
|
os.Stdout.Write(output)
|
||||||
|
}
|
||||||
|
|
||||||
42
examples/functions/hello/main.go
Normal file
42
examples/functions/hello/main.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// Example: Hello function
|
||||||
|
// This is a simple serverless function that returns a greeting.
|
||||||
|
// Compile with: tinygo build -o hello.wasm -target wasi main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Read input from stdin
|
||||||
|
var input []byte
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
input = append(input, buf[:n]...)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse input to get name
|
||||||
|
var payload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &payload); err != nil || payload.Name == "" {
|
||||||
|
payload.Name = "World"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create greeting response
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"greeting": "Hello, " + payload.Name + "!",
|
||||||
|
"message": "This is a serverless function running on Orama Network",
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := json.Marshal(response)
|
||||||
|
os.Stdout.Write(output)
|
||||||
|
}
|
||||||
|
|
||||||
7
go.mod
7
go.mod
@ -1,6 +1,6 @@
|
|||||||
module github.com/DeBrosOfficial/network
|
module github.com/DeBrosOfficial/network
|
||||||
|
|
||||||
go 1.23.8
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.24.1
|
toolchain go1.24.1
|
||||||
|
|
||||||
@ -10,6 +10,7 @@ require (
|
|||||||
github.com/charmbracelet/lipgloss v1.0.0
|
github.com/charmbracelet/lipgloss v1.0.0
|
||||||
github.com/ethereum/go-ethereum v1.13.14
|
github.com/ethereum/go-ethereum v1.13.14
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/libp2p/go-libp2p v0.41.1
|
github.com/libp2p/go-libp2p v0.41.1
|
||||||
github.com/libp2p/go-libp2p-pubsub v0.14.2
|
github.com/libp2p/go-libp2p-pubsub v0.14.2
|
||||||
@ -18,6 +19,7 @@ require (
|
|||||||
github.com/multiformats/go-multiaddr v0.15.0
|
github.com/multiformats/go-multiaddr v0.15.0
|
||||||
github.com/olric-data/olric v0.7.0
|
github.com/olric-data/olric v0.7.0
|
||||||
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8
|
||||||
|
github.com/tetratelabs/wazero v1.11.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/net v0.42.0
|
golang.org/x/net v0.42.0
|
||||||
@ -54,7 +56,6 @@ require (
|
|||||||
github.com/google/btree v1.1.3 // indirect
|
github.com/google/btree v1.1.3 // indirect
|
||||||
github.com/google/gopacket v1.1.19 // indirect
|
github.com/google/gopacket v1.1.19 // indirect
|
||||||
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect
|
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||||
@ -154,7 +155,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
|
||||||
golang.org/x/mod v0.26.0 // indirect
|
golang.org/x/mod v0.26.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.27.0 // indirect
|
||||||
golang.org/x/tools v0.35.0 // indirect
|
golang.org/x/tools v0.35.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@ -487,6 +487,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
|||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||||
|
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||||
|
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||||
github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
|
github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
|
||||||
github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI=
|
github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI=
|
||||||
github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
|
github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
|
||||||
@ -627,8 +629,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
|||||||
243
migrations/004_serverless_functions.sql
Normal file
243
migrations/004_serverless_functions.sql
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
-- Orama Network - Serverless Functions Engine (Phase 4)
|
||||||
|
-- WASM-based serverless function execution with triggers, jobs, and secrets
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FUNCTIONS TABLE
|
||||||
|
-- Core function registry with versioning support
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS functions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
wasm_cid TEXT NOT NULL,
|
||||||
|
source_cid TEXT,
|
||||||
|
memory_limit_mb INTEGER NOT NULL DEFAULT 64,
|
||||||
|
timeout_seconds INTEGER NOT NULL DEFAULT 30,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retry_delay_seconds INTEGER NOT NULL DEFAULT 5,
|
||||||
|
dlq_topic TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
UNIQUE(namespace, name, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_functions_namespace ON functions(namespace);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_functions_name ON functions(namespace, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_functions_status ON functions(status);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FUNCTION ENVIRONMENT VARIABLES
|
||||||
|
-- Non-sensitive configuration per function
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_env_vars (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(function_id, key),
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_env_vars_function ON function_env_vars(function_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FUNCTION SECRETS
|
||||||
|
-- Encrypted secrets per namespace (shared across functions in namespace)
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_secrets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
encrypted_value BLOB NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(namespace, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_secrets_namespace ON function_secrets(namespace);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- CRON TRIGGERS
|
||||||
|
-- Scheduled function execution using cron expressions
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_cron_triggers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
cron_expression TEXT NOT NULL,
|
||||||
|
next_run_at TIMESTAMP,
|
||||||
|
last_run_at TIMESTAMP,
|
||||||
|
last_status TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_cron_triggers_function ON function_cron_triggers(function_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_cron_triggers_next_run ON function_cron_triggers(next_run_at)
|
||||||
|
WHERE enabled = TRUE;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- DATABASE TRIGGERS
|
||||||
|
-- Trigger functions on database changes (INSERT/UPDATE/DELETE)
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_db_triggers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
table_name TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL CHECK(operation IN ('INSERT', 'UPDATE', 'DELETE')),
|
||||||
|
condition TEXT,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_db_triggers_function ON function_db_triggers(function_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_db_triggers_table ON function_db_triggers(table_name, operation)
|
||||||
|
WHERE enabled = TRUE;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- PUBSUB TRIGGERS
|
||||||
|
-- Trigger functions on pubsub messages
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_pubsub_triggers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_function ON function_pubsub_triggers(function_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_topic ON function_pubsub_triggers(topic)
|
||||||
|
WHERE enabled = TRUE;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- ONE-TIME TIMERS
|
||||||
|
-- Schedule functions to run once at a specific time
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_timers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
run_at TIMESTAMP NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'completed', 'failed')),
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_timers_function ON function_timers(function_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_timers_pending ON function_timers(run_at)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- BACKGROUND JOBS
|
||||||
|
-- Long-running async function execution
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0 CHECK(progress >= 0 AND progress <= 100),
|
||||||
|
result TEXT,
|
||||||
|
error TEXT,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_jobs_function ON function_jobs(function_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_jobs_status ON function_jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_jobs_pending ON function_jobs(created_at)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- INVOCATION LOGS
|
||||||
|
-- Record of all function invocations for debugging and metrics
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_invocations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
trigger_type TEXT NOT NULL,
|
||||||
|
caller_wallet TEXT,
|
||||||
|
input_size INTEGER,
|
||||||
|
output_size INTEGER,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
status TEXT CHECK(status IN ('success', 'error', 'timeout')),
|
||||||
|
error_message TEXT,
|
||||||
|
memory_used_mb REAL,
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_invocations_function ON function_invocations(function_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_invocations_request ON function_invocations(request_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_invocations_time ON function_invocations(started_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_invocations_status ON function_invocations(function_id, status);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FUNCTION LOGS
|
||||||
|
-- Captured log output from function execution
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
function_id TEXT NOT NULL,
|
||||||
|
invocation_id TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('info', 'warn', 'error', 'debug')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (invocation_id) REFERENCES function_invocations(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_logs_invocation ON function_logs(invocation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_logs_function ON function_logs(function_id, timestamp);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- DB CHANGE TRACKING
|
||||||
|
-- Track last processed row for database triggers (CDC-like)
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_db_change_tracking (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
trigger_id TEXT NOT NULL UNIQUE,
|
||||||
|
last_row_id INTEGER,
|
||||||
|
last_updated_at TIMESTAMP,
|
||||||
|
last_check_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (trigger_id) REFERENCES function_db_triggers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- RATE LIMITING
|
||||||
|
-- Track request counts for rate limiting
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS function_rate_limits (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
window_key TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
window_start TIMESTAMP NOT NULL,
|
||||||
|
UNIQUE(window_key, window_start)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_function_rate_limits_window ON function_rate_limits(window_key, window_start);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- MIGRATION VERSION TRACKING
|
||||||
|
-- =============================================================================
|
||||||
|
INSERT OR IGNORE INTO schema_migrations(version) VALUES (4);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
@ -97,9 +97,9 @@ func runInteractiveInstaller() {
|
|||||||
|
|
||||||
// showDryRunSummary displays what would be done during installation without making changes
|
// showDryRunSummary displays what would be done during installation without making changes
|
||||||
func showDryRunSummary(vpsIP, domain, branch string, peers []string, joinAddress string, isFirstNode bool, oramaDir string) {
|
func showDryRunSummary(vpsIP, domain, branch string, peers []string, joinAddress string, isFirstNode bool, oramaDir string) {
|
||||||
fmt.Printf("\n" + strings.Repeat("=", 70) + "\n")
|
fmt.Print("\n" + strings.Repeat("=", 70) + "\n")
|
||||||
fmt.Printf("DRY RUN - No changes will be made\n")
|
fmt.Printf("DRY RUN - No changes will be made\n")
|
||||||
fmt.Printf(strings.Repeat("=", 70) + "\n\n")
|
fmt.Print(strings.Repeat("=", 70) + "\n\n")
|
||||||
|
|
||||||
fmt.Printf("📋 Installation Summary:\n")
|
fmt.Printf("📋 Installation Summary:\n")
|
||||||
fmt.Printf(" VPS IP: %s\n", vpsIP)
|
fmt.Printf(" VPS IP: %s\n", vpsIP)
|
||||||
@ -169,9 +169,9 @@ func showDryRunSummary(vpsIP, domain, branch string, peers []string, joinAddress
|
|||||||
fmt.Printf(" - 9094 (IPFS Cluster API)\n")
|
fmt.Printf(" - 9094 (IPFS Cluster API)\n")
|
||||||
fmt.Printf(" - 3320/3322 (Olric)\n")
|
fmt.Printf(" - 3320/3322 (Olric)\n")
|
||||||
|
|
||||||
fmt.Printf("\n" + strings.Repeat("=", 70) + "\n")
|
fmt.Print("\n" + strings.Repeat("=", 70) + "\n")
|
||||||
fmt.Printf("To proceed with installation, run without --dry-run\n")
|
fmt.Printf("To proceed with installation, run without --dry-run\n")
|
||||||
fmt.Printf(strings.Repeat("=", 70) + "\n\n")
|
fmt.Print(strings.Repeat("=", 70) + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateGeneratedConfig loads and validates the generated node configuration
|
// validateGeneratedConfig loads and validates the generated node configuration
|
||||||
@ -425,12 +425,12 @@ func handleProdInstall(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate VPS IP is provided
|
// Validate VPS IP is provided
|
||||||
if *vpsIP == "" {
|
if *vpsIP == "" {
|
||||||
fmt.Fprintf(os.Stderr, "❌ --vps-ip is required\n")
|
fmt.Fprintf(os.Stderr, "❌ --vps-ip is required\n")
|
||||||
fmt.Fprintf(os.Stderr, " Usage: sudo orama install --vps-ip <public_ip>\n")
|
fmt.Fprintf(os.Stderr, " Usage: sudo orama install --vps-ip <public_ip>\n")
|
||||||
fmt.Fprintf(os.Stderr, " Or run: sudo orama install --interactive\n")
|
fmt.Fprintf(os.Stderr, " Or run: sudo orama install --interactive\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if this is the first node (creates new cluster) or joining existing cluster
|
// Determine if this is the first node (creates new cluster) or joining existing cluster
|
||||||
isFirstNode := len(peers) == 0 && *joinAddress == ""
|
isFirstNode := len(peers) == 0 && *joinAddress == ""
|
||||||
@ -1109,7 +1109,7 @@ func handleProdLogs(args []string) {
|
|||||||
} else {
|
} else {
|
||||||
for i, svc := range serviceNames {
|
for i, svc := range serviceNames {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
fmt.Printf("\n" + strings.Repeat("=", 70) + "\n\n")
|
fmt.Print("\n" + strings.Repeat("=", 70) + "\n\n")
|
||||||
}
|
}
|
||||||
fmt.Printf("📋 Logs for %s:\n\n", svc)
|
fmt.Printf("📋 Logs for %s:\n\n", svc)
|
||||||
cmd := exec.Command("journalctl", "-u", svc, "-n", "50")
|
cmd := exec.Command("journalctl", "-u", svc, "-n", "50")
|
||||||
|
|||||||
@ -78,7 +78,7 @@ func (dc *DependencyChecker) CheckAll() ([]string, error) {
|
|||||||
|
|
||||||
errMsg := fmt.Sprintf("Missing %d required dependencies:\n%s\n\nInstall them with:\n%s",
|
errMsg := fmt.Sprintf("Missing %d required dependencies:\n%s\n\nInstall them with:\n%s",
|
||||||
len(missing), strings.Join(missing, ", "), strings.Join(hints, "\n"))
|
len(missing), strings.Join(missing, ", "), strings.Join(hints, "\n"))
|
||||||
return missing, fmt.Errorf(errMsg)
|
return missing, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PortChecker validates that required ports are available
|
// PortChecker validates that required ports are available
|
||||||
@ -113,7 +113,7 @@ func (pc *PortChecker) CheckAll() ([]int, error) {
|
|||||||
|
|
||||||
errMsg := fmt.Sprintf("The following ports are unavailable: %v\n\nFree them or stop conflicting services and try again",
|
errMsg := fmt.Sprintf("The following ports are unavailable: %v\n\nFree them or stop conflicting services and try again",
|
||||||
unavailable)
|
unavailable)
|
||||||
return unavailable, fmt.Errorf(errMsg)
|
return unavailable, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPortAvailable checks if a TCP port is available for binding
|
// isPortAvailable checks if a TCP port is available for binding
|
||||||
|
|||||||
@ -20,7 +20,9 @@ import (
|
|||||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
"github.com/DeBrosOfficial/network/pkg/logging"
|
||||||
"github.com/DeBrosOfficial/network/pkg/olric"
|
"github.com/DeBrosOfficial/network/pkg/olric"
|
||||||
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/serverless"
|
||||||
"github.com/multiformats/go-multiaddr"
|
"github.com/multiformats/go-multiaddr"
|
||||||
|
olriclib "github.com/olric-data/olric"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
_ "github.com/rqlite/gorqlite/stdlib"
|
_ "github.com/rqlite/gorqlite/stdlib"
|
||||||
@ -84,6 +86,13 @@ type Gateway struct {
|
|||||||
// Local pub/sub bypass for same-gateway subscribers
|
// Local pub/sub bypass for same-gateway subscribers
|
||||||
localSubscribers map[string][]*localSubscriber // topic+namespace -> subscribers
|
localSubscribers map[string][]*localSubscriber // topic+namespace -> subscribers
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// Serverless function engine
|
||||||
|
serverlessEngine *serverless.Engine
|
||||||
|
serverlessRegistry *serverless.Registry
|
||||||
|
serverlessInvoker *serverless.Invoker
|
||||||
|
serverlessWSMgr *serverless.WSManager
|
||||||
|
serverlessHandlers *ServerlessHandlers
|
||||||
}
|
}
|
||||||
|
|
||||||
// localSubscriber represents a WebSocket subscriber for local message delivery
|
// localSubscriber represents a WebSocket subscriber for local message delivery
|
||||||
@ -298,6 +307,78 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
|||||||
gw.cfg.IPFSReplicationFactor = ipfsReplicationFactor
|
gw.cfg.IPFSReplicationFactor = ipfsReplicationFactor
|
||||||
gw.cfg.IPFSEnableEncryption = ipfsEnableEncryption
|
gw.cfg.IPFSEnableEncryption = ipfsEnableEncryption
|
||||||
|
|
||||||
|
// Initialize serverless function engine
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Initializing serverless function engine...")
|
||||||
|
if gw.ormClient != nil && gw.ipfsClient != nil {
|
||||||
|
// Create serverless registry (stores functions in RQLite + IPFS)
|
||||||
|
registryCfg := serverless.RegistryConfig{
|
||||||
|
IPFSAPIURL: ipfsAPIURL,
|
||||||
|
}
|
||||||
|
registry := serverless.NewRegistry(gw.ormClient, gw.ipfsClient, registryCfg, logger.Logger)
|
||||||
|
gw.serverlessRegistry = registry
|
||||||
|
|
||||||
|
// Create WebSocket manager for function streaming
|
||||||
|
gw.serverlessWSMgr = serverless.NewWSManager(logger.Logger)
|
||||||
|
|
||||||
|
// Get underlying Olric client if available
|
||||||
|
var olricClient olriclib.Client
|
||||||
|
if oc := gw.getOlricClient(); oc != nil {
|
||||||
|
olricClient = oc.UnderlyingClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create host functions provider (allows functions to call Orama services)
|
||||||
|
// Note: pubsub and secrets are nil for now - can be added later
|
||||||
|
hostFuncsCfg := serverless.HostFunctionsConfig{
|
||||||
|
IPFSAPIURL: ipfsAPIURL,
|
||||||
|
HTTPTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
hostFuncs := serverless.NewHostFunctions(
|
||||||
|
gw.ormClient,
|
||||||
|
olricClient,
|
||||||
|
gw.ipfsClient,
|
||||||
|
nil, // pubsub adapter - TODO: integrate with gateway pubsub
|
||||||
|
gw.serverlessWSMgr,
|
||||||
|
nil, // secrets manager - TODO: implement
|
||||||
|
hostFuncsCfg,
|
||||||
|
logger.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create WASM engine configuration
|
||||||
|
engineCfg := serverless.DefaultConfig()
|
||||||
|
engineCfg.DefaultMemoryLimitMB = 128
|
||||||
|
engineCfg.MaxMemoryLimitMB = 256
|
||||||
|
engineCfg.DefaultTimeoutSeconds = 30
|
||||||
|
engineCfg.MaxTimeoutSeconds = 60
|
||||||
|
engineCfg.ModuleCacheSize = 100
|
||||||
|
|
||||||
|
// Create WASM engine
|
||||||
|
engine, engineErr := serverless.NewEngine(engineCfg, registry, hostFuncs, logger.Logger)
|
||||||
|
if engineErr != nil {
|
||||||
|
logger.ComponentWarn(logging.ComponentGeneral, "failed to initialize serverless engine; functions disabled", zap.Error(engineErr))
|
||||||
|
} else {
|
||||||
|
gw.serverlessEngine = engine
|
||||||
|
|
||||||
|
// Create invoker
|
||||||
|
gw.serverlessInvoker = serverless.NewInvoker(engine, registry, hostFuncs, logger.Logger)
|
||||||
|
|
||||||
|
// Create HTTP handlers
|
||||||
|
gw.serverlessHandlers = NewServerlessHandlers(
|
||||||
|
gw.serverlessInvoker,
|
||||||
|
registry,
|
||||||
|
gw.serverlessWSMgr,
|
||||||
|
logger.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Serverless function engine ready",
|
||||||
|
zap.Int("default_memory_mb", engineCfg.DefaultMemoryLimitMB),
|
||||||
|
zap.Int("default_timeout_sec", engineCfg.DefaultTimeoutSeconds),
|
||||||
|
zap.Int("module_cache_size", engineCfg.ModuleCacheSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.ComponentWarn(logging.ComponentGeneral, "serverless engine requires RQLite and IPFS; functions disabled")
|
||||||
|
}
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentGeneral, "Gateway creation completed, returning...")
|
logger.ComponentInfo(logging.ComponentGeneral, "Gateway creation completed, returning...")
|
||||||
return gw, nil
|
return gw, nil
|
||||||
}
|
}
|
||||||
@ -309,6 +390,14 @@ func (g *Gateway) withInternalAuth(ctx context.Context) context.Context {
|
|||||||
|
|
||||||
// Close disconnects the gateway client
|
// Close disconnects the gateway client
|
||||||
func (g *Gateway) Close() {
|
func (g *Gateway) Close() {
|
||||||
|
// Close serverless engine first
|
||||||
|
if g.serverlessEngine != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
if err := g.serverlessEngine.Close(ctx); err != nil {
|
||||||
|
g.logger.ComponentWarn(logging.ComponentGeneral, "error during serverless engine close", zap.Error(err))
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
if g.client != nil {
|
if g.client != nil {
|
||||||
if err := g.client.Disconnect(); err != nil {
|
if err := g.client.Disconnect(); err != nil {
|
||||||
g.logger.ComponentWarn(logging.ComponentClient, "error during client disconnect", zap.Error(err))
|
g.logger.ComponentWarn(logging.ComponentClient, "error during client disconnect", zap.Error(err))
|
||||||
|
|||||||
@ -63,5 +63,10 @@ func (g *Gateway) Routes() http.Handler {
|
|||||||
mux.HandleFunc("/v1/storage/get/", g.storageGetHandler)
|
mux.HandleFunc("/v1/storage/get/", g.storageGetHandler)
|
||||||
mux.HandleFunc("/v1/storage/unpin/", g.storageUnpinHandler)
|
mux.HandleFunc("/v1/storage/unpin/", g.storageUnpinHandler)
|
||||||
|
|
||||||
|
// serverless functions (if enabled)
|
||||||
|
if g.serverlessHandlers != nil {
|
||||||
|
g.serverlessHandlers.RegisterRoutes(mux)
|
||||||
|
}
|
||||||
|
|
||||||
return g.withMiddleware(mux)
|
return g.withMiddleware(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
600
pkg/gateway/serverless_handlers.go
Normal file
600
pkg/gateway/serverless_handlers.go
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/serverless"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerlessHandlers contains handlers for serverless function endpoints.
|
||||||
|
// It's a separate struct to keep the Gateway struct clean.
|
||||||
|
type ServerlessHandlers struct {
|
||||||
|
invoker *serverless.Invoker
|
||||||
|
registry serverless.FunctionRegistry
|
||||||
|
wsManager *serverless.WSManager
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerlessHandlers creates a new ServerlessHandlers instance.
|
||||||
|
func NewServerlessHandlers(
|
||||||
|
invoker *serverless.Invoker,
|
||||||
|
registry serverless.FunctionRegistry,
|
||||||
|
wsManager *serverless.WSManager,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *ServerlessHandlers {
|
||||||
|
return &ServerlessHandlers{
|
||||||
|
invoker: invoker,
|
||||||
|
registry: registry,
|
||||||
|
wsManager: wsManager,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers all serverless routes on the given mux.
|
||||||
|
func (h *ServerlessHandlers) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
// Function management
|
||||||
|
mux.HandleFunc("/v1/functions", h.handleFunctions)
|
||||||
|
mux.HandleFunc("/v1/functions/", h.handleFunctionByName)
|
||||||
|
|
||||||
|
// Direct invoke endpoint
|
||||||
|
mux.HandleFunc("/v1/invoke/", h.handleInvoke)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFunctions handles GET /v1/functions (list) and POST /v1/functions (deploy)
|
||||||
|
func (h *ServerlessHandlers) handleFunctions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.listFunctions(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
h.deployFunction(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFunctionByName handles operations on a specific function
|
||||||
|
// Routes:
|
||||||
|
// - GET /v1/functions/{name} - Get function info
|
||||||
|
// - DELETE /v1/functions/{name} - Delete function
|
||||||
|
// - POST /v1/functions/{name}/invoke - Invoke function
|
||||||
|
// - GET /v1/functions/{name}/versions - List versions
|
||||||
|
// - GET /v1/functions/{name}/logs - Get logs
|
||||||
|
// - WS /v1/functions/{name}/ws - WebSocket invoke
|
||||||
|
func (h *ServerlessHandlers) handleFunctionByName(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse path: /v1/functions/{name}[/{action}]
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/v1/functions/")
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
|
||||||
|
if len(parts) == 0 || parts[0] == "" {
|
||||||
|
http.Error(w, "Function name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := parts[0]
|
||||||
|
action := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
action = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse version from name if present (e.g., "myfunction@2")
|
||||||
|
version := 0
|
||||||
|
if idx := strings.Index(name, "@"); idx > 0 {
|
||||||
|
vStr := name[idx+1:]
|
||||||
|
name = name[:idx]
|
||||||
|
if v, err := strconv.Atoi(vStr); err == nil {
|
||||||
|
version = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "invoke":
|
||||||
|
h.invokeFunction(w, r, name, version)
|
||||||
|
case "ws":
|
||||||
|
h.handleWebSocket(w, r, name, version)
|
||||||
|
case "versions":
|
||||||
|
h.listVersions(w, r, name)
|
||||||
|
case "logs":
|
||||||
|
h.getFunctionLogs(w, r, name)
|
||||||
|
case "":
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.getFunctionInfo(w, r, name, version)
|
||||||
|
case http.MethodDelete:
|
||||||
|
h.deleteFunction(w, r, name, version)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.Error(w, "Unknown action", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInvoke handles POST /v1/invoke/{namespace}/{name}[@version]
|
||||||
|
func (h *ServerlessHandlers) handleInvoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse path: /v1/invoke/{namespace}/{name}[@version]
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/v1/invoke/")
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
http.Error(w, "Path must be /v1/invoke/{namespace}/{name}", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace := parts[0]
|
||||||
|
name := parts[1]
|
||||||
|
|
||||||
|
// Parse version if present
|
||||||
|
version := 0
|
||||||
|
if idx := strings.Index(name, "@"); idx > 0 {
|
||||||
|
vStr := name[idx+1:]
|
||||||
|
name = name[:idx]
|
||||||
|
if v, err := strconv.Atoi(vStr); err == nil {
|
||||||
|
version = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.invokeFunction(w, r, namespace+"/"+name, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listFunctions handles GET /v1/functions
|
||||||
|
func (h *ServerlessHandlers) listFunctions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
// Get namespace from JWT if available
|
||||||
|
namespace = h.getNamespaceFromRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "namespace required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
functions, err := h.registry.List(ctx, namespace)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to list functions", zap.Error(err))
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to list functions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"functions": functions,
|
||||||
|
"count": len(functions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deployFunction handles POST /v1/functions
|
||||||
|
func (h *ServerlessHandlers) deployFunction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse multipart form (for WASM upload) or JSON
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
var def serverless.FunctionDefinition
|
||||||
|
var wasmBytes []byte
|
||||||
|
|
||||||
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||||
|
// Parse multipart form
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
|
||||||
|
writeError(w, http.StatusBadRequest, "Failed to parse form: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata from form field
|
||||||
|
metadataStr := r.FormValue("metadata")
|
||||||
|
if metadataStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(metadataStr), &def); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid metadata JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get name from form if not in metadata
|
||||||
|
if def.Name == "" {
|
||||||
|
def.Name = r.FormValue("name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get WASM file
|
||||||
|
file, _, err := r.FormFile("wasm")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "WASM file required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
wasmBytes, err = io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Failed to read WASM file: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// JSON body with base64-encoded WASM
|
||||||
|
var req struct {
|
||||||
|
serverless.FunctionDefinition
|
||||||
|
WASMBase64 string `json:"wasm_base64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
def = req.FunctionDefinition
|
||||||
|
|
||||||
|
if req.WASMBase64 != "" {
|
||||||
|
// Decode base64 WASM - for now, just reject this method
|
||||||
|
writeError(w, http.StatusBadRequest, "Base64 WASM upload not supported, use multipart/form-data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get namespace from JWT if not provided
|
||||||
|
if def.Namespace == "" {
|
||||||
|
def.Namespace = h.getNamespaceFromRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Function name required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if def.Namespace == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Namespace required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(wasmBytes) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "WASM bytecode required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.registry.Register(ctx, &def, wasmBytes); err != nil {
|
||||||
|
h.logger.Error("Failed to deploy function",
|
||||||
|
zap.String("name", def.Name),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to deploy: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Function deployed",
|
||||||
|
zap.String("name", def.Name),
|
||||||
|
zap.String("namespace", def.Namespace),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch the deployed function to return
|
||||||
|
fn, err := h.registry.Get(ctx, def.Namespace, def.Name, def.Version)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"message": "Function deployed successfully",
|
||||||
|
"name": def.Name,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"message": "Function deployed successfully",
|
||||||
|
"function": fn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFunctionInfo handles GET /v1/functions/{name}
|
||||||
|
func (h *ServerlessHandlers) getFunctionInfo(w http.ResponseWriter, r *http.Request, name string, version int) {
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = h.getNamespaceFromRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "namespace required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fn, err := h.registry.Get(ctx, namespace, name, version)
|
||||||
|
if err != nil {
|
||||||
|
if serverless.IsNotFound(err) {
|
||||||
|
writeError(w, http.StatusNotFound, "Function not found")
|
||||||
|
} else {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to get function")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFunction handles DELETE /v1/functions/{name}
|
||||||
|
func (h *ServerlessHandlers) deleteFunction(w http.ResponseWriter, r *http.Request, name string, version int) {
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = h.getNamespaceFromRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "namespace required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.registry.Delete(ctx, namespace, name, version); err != nil {
|
||||||
|
if serverless.IsNotFound(err) {
|
||||||
|
writeError(w, http.StatusNotFound, "Function not found")
|
||||||
|
} else {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to delete function")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Function deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// invokeFunction handles POST /v1/functions/{name}/invoke
|
||||||
|
func (h *ServerlessHandlers) invokeFunction(w http.ResponseWriter, r *http.Request, nameWithNS string, version int) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse namespace and name
|
||||||
|
var namespace, name string
|
||||||
|
if idx := strings.Index(nameWithNS, "/"); idx > 0 {
|
||||||
|
namespace = nameWithNS[:idx]
|
||||||
|
name = nameWithNS[idx+1:]
|
||||||
|
} else {
|
||||||
|
name = nameWithNS
|
||||||
|
namespace = r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = h.getNamespaceFromRequest(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "namespace required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read input body
|
||||||
|
input, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB max
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Failed to read request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get caller wallet from JWT
|
||||||
|
callerWallet := h.getWalletFromRequest(r)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := &serverless.InvokeRequest{
|
||||||
|
Namespace: namespace,
|
||||||
|
FunctionName: name,
|
||||||
|
Version: version,
|
||||||
|
Input: input,
|
||||||
|
TriggerType: serverless.TriggerTypeHTTP,
|
||||||
|
CallerWallet: callerWallet,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.invoker.Invoke(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
statusCode := http.StatusInternalServerError
|
||||||
|
if serverless.IsNotFound(err) {
|
||||||
|
statusCode = http.StatusNotFound
|
||||||
|
} else if serverless.IsResourceExhausted(err) {
|
||||||
|
statusCode = http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, statusCode, map[string]interface{}{
|
||||||
|
"request_id": resp.RequestID,
|
||||||
|
"status": resp.Status,
|
||||||
|
"error": resp.Error,
|
||||||
|
"duration_ms": resp.DurationMS,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the function's output directly if it's JSON
|
||||||
|
w.Header().Set("X-Request-ID", resp.RequestID)
|
||||||
|
w.Header().Set("X-Duration-Ms", strconv.FormatInt(resp.DurationMS, 10))
|
||||||
|
|
||||||
|
// Try to detect if output is JSON
|
||||||
|
if len(resp.Output) > 0 && (resp.Output[0] == '{' || resp.Output[0] == '[') {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(resp.Output)
|
||||||
|
} else {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"request_id": resp.RequestID,
|
||||||
|
"output": string(resp.Output),
|
||||||
|
"status": resp.Status,
|
||||||
|
"duration_ms": resp.DurationMS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebSocket handles WebSocket connections for function streaming
|
||||||
|
func (h *ServerlessHandlers) handleWebSocket(w http.ResponseWriter, r *http.Request, name string, version int) {
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = h.getNamespaceFromRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace == "" {
|
||||||
|
http.Error(w, "namespace required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade to WebSocket
|
||||||
|
upgrader := websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("WebSocket upgrade failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := uuid.New().String()
|
||||||
|
wsConn := &serverless.GorillaWSConn{Conn: conn}
|
||||||
|
|
||||||
|
// Register connection
|
||||||
|
h.wsManager.Register(clientID, wsConn)
|
||||||
|
defer h.wsManager.Unregister(clientID)
|
||||||
|
|
||||||
|
h.logger.Info("WebSocket connected",
|
||||||
|
zap.String("client_id", clientID),
|
||||||
|
zap.String("function", name),
|
||||||
|
)
|
||||||
|
|
||||||
|
callerWallet := h.getWalletFromRequest(r)
|
||||||
|
|
||||||
|
// Message loop
|
||||||
|
for {
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
h.logger.Warn("WebSocket error", zap.Error(err))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke function with WebSocket context
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
|
||||||
|
req := &serverless.InvokeRequest{
|
||||||
|
Namespace: namespace,
|
||||||
|
FunctionName: name,
|
||||||
|
Version: version,
|
||||||
|
Input: message,
|
||||||
|
TriggerType: serverless.TriggerTypeWebSocket,
|
||||||
|
CallerWallet: callerWallet,
|
||||||
|
WSClientID: clientID,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.invoker.Invoke(ctx, req)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Send response back
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"request_id": resp.RequestID,
|
||||||
|
"status": resp.Status,
|
||||||
|
"duration_ms": resp.DurationMS,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
response["error"] = resp.Error
|
||||||
|
} else if len(resp.Output) > 0 {
|
||||||
|
// Try to parse output as JSON
|
||||||
|
var output interface{}
|
||||||
|
if json.Unmarshal(resp.Output, &output) == nil {
|
||||||
|
response["output"] = output
|
||||||
|
} else {
|
||||||
|
response["output"] = string(resp.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respBytes, _ := json.Marshal(response)
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, respBytes); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listVersions handles GET /v1/functions/{name}/versions
|
||||||
|
func (h *ServerlessHandlers) listVersions(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = h.getNamespaceFromRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if namespace == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "namespace required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get registry with extended methods
|
||||||
|
reg, ok := h.registry.(*serverless.Registry)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotImplemented, "Version listing not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := reg.ListVersions(ctx, namespace, name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Failed to list versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"versions": versions,
|
||||||
|
"count": len(versions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFunctionLogs handles GET /v1/functions/{name}/logs
|
||||||
|
func (h *ServerlessHandlers) getFunctionLogs(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
// TODO: Implement log retrieval from function_logs table
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"logs": []interface{}{},
|
||||||
|
"message": "Log retrieval not yet implemented",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNamespaceFromRequest extracts namespace from JWT or query param
|
||||||
|
func (h *ServerlessHandlers) getNamespaceFromRequest(r *http.Request) string {
|
||||||
|
// Try query param first
|
||||||
|
if ns := r.URL.Query().Get("namespace"); ns != "" {
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from JWT (if authentication middleware has set it)
|
||||||
|
if ns := r.Header.Get("X-Namespace"); ns != "" {
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWalletFromRequest extracts wallet address from JWT
|
||||||
|
func (h *ServerlessHandlers) getWalletFromRequest(r *http.Request) string {
|
||||||
|
if wallet := r.Header.Get("X-Wallet"); wallet != "" {
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthStatus returns the health status of the serverless engine
|
||||||
|
func (h *ServerlessHandlers) HealthStatus() map[string]interface{} {
|
||||||
|
stats := h.wsManager.GetStats()
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"connections": stats.ConnectionCount,
|
||||||
|
"topics": stats.TopicCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,13 @@ func NewClient(cfg Config, logger *zap.Logger) (*Client, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnderlyingClient returns the underlying olriclib.Client for advanced usage.
|
||||||
|
// This is useful when you need to pass the client to other packages that expect
|
||||||
|
// the raw olric client interface.
|
||||||
|
func (c *Client) UnderlyingClient() olriclib.Client {
|
||||||
|
return c.client
|
||||||
|
}
|
||||||
|
|
||||||
// Health checks if the Olric client is healthy
|
// Health checks if the Olric client is healthy
|
||||||
func (c *Client) Health(ctx context.Context) error {
|
func (c *Client) Health(ctx context.Context) error {
|
||||||
// Create a DMap to test connectivity
|
// Create a DMap to test connectivity
|
||||||
|
|||||||
@ -595,10 +595,19 @@ func setReflectValue(field reflect.Value, raw any) error {
|
|||||||
switch v := raw.(type) {
|
switch v := raw.(type) {
|
||||||
case int64:
|
case int64:
|
||||||
field.SetInt(v)
|
field.SetInt(v)
|
||||||
|
case float64:
|
||||||
|
// RQLite/JSON returns numbers as float64
|
||||||
|
field.SetInt(int64(v))
|
||||||
|
case int:
|
||||||
|
field.SetInt(int64(v))
|
||||||
case []byte:
|
case []byte:
|
||||||
var n int64
|
var n int64
|
||||||
fmt.Sscan(string(v), &n)
|
fmt.Sscan(string(v), &n)
|
||||||
field.SetInt(n)
|
field.SetInt(n)
|
||||||
|
case string:
|
||||||
|
var n int64
|
||||||
|
fmt.Sscan(v, &n)
|
||||||
|
field.SetInt(n)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("cannot convert %T to int", raw)
|
return fmt.Errorf("cannot convert %T to int", raw)
|
||||||
}
|
}
|
||||||
@ -609,10 +618,22 @@ func setReflectValue(field reflect.Value, raw any) error {
|
|||||||
v = 0
|
v = 0
|
||||||
}
|
}
|
||||||
field.SetUint(uint64(v))
|
field.SetUint(uint64(v))
|
||||||
|
case float64:
|
||||||
|
// RQLite/JSON returns numbers as float64
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
field.SetUint(uint64(v))
|
||||||
|
case uint64:
|
||||||
|
field.SetUint(v)
|
||||||
case []byte:
|
case []byte:
|
||||||
var n uint64
|
var n uint64
|
||||||
fmt.Sscan(string(v), &n)
|
fmt.Sscan(string(v), &n)
|
||||||
field.SetUint(n)
|
field.SetUint(n)
|
||||||
|
case string:
|
||||||
|
var n uint64
|
||||||
|
fmt.Sscan(v, &n)
|
||||||
|
field.SetUint(n)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("cannot convert %T to uint", raw)
|
return fmt.Errorf("cannot convert %T to uint", raw)
|
||||||
}
|
}
|
||||||
@ -628,11 +649,16 @@ func setReflectValue(field reflect.Value, raw any) error {
|
|||||||
return fmt.Errorf("cannot convert %T to float", raw)
|
return fmt.Errorf("cannot convert %T to float", raw)
|
||||||
}
|
}
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
// Support time.Time; extend as needed.
|
// Support time.Time
|
||||||
if field.Type() == reflect.TypeOf(time.Time{}) {
|
if field.Type() == reflect.TypeOf(time.Time{}) {
|
||||||
switch v := raw.(type) {
|
switch v := raw.(type) {
|
||||||
case time.Time:
|
case time.Time:
|
||||||
field.Set(reflect.ValueOf(v))
|
field.Set(reflect.ValueOf(v))
|
||||||
|
case string:
|
||||||
|
// Try RFC3339
|
||||||
|
if tt, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
field.Set(reflect.ValueOf(tt))
|
||||||
|
}
|
||||||
case []byte:
|
case []byte:
|
||||||
// Try RFC3339
|
// Try RFC3339
|
||||||
if tt, err := time.Parse(time.RFC3339, string(v)); err == nil {
|
if tt, err := time.Parse(time.RFC3339, string(v)); err == nil {
|
||||||
@ -641,6 +667,68 @@ func setReflectValue(field reflect.Value, raw any) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Support sql.NullString
|
||||||
|
if field.Type() == reflect.TypeOf(sql.NullString{}) {
|
||||||
|
ns := sql.NullString{}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
ns.String = v
|
||||||
|
ns.Valid = true
|
||||||
|
case []byte:
|
||||||
|
ns.String = string(v)
|
||||||
|
ns.Valid = true
|
||||||
|
}
|
||||||
|
field.Set(reflect.ValueOf(ns))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Support sql.NullInt64
|
||||||
|
if field.Type() == reflect.TypeOf(sql.NullInt64{}) {
|
||||||
|
ni := sql.NullInt64{}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case int64:
|
||||||
|
ni.Int64 = v
|
||||||
|
ni.Valid = true
|
||||||
|
case float64:
|
||||||
|
ni.Int64 = int64(v)
|
||||||
|
ni.Valid = true
|
||||||
|
case int:
|
||||||
|
ni.Int64 = int64(v)
|
||||||
|
ni.Valid = true
|
||||||
|
}
|
||||||
|
field.Set(reflect.ValueOf(ni))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Support sql.NullBool
|
||||||
|
if field.Type() == reflect.TypeOf(sql.NullBool{}) {
|
||||||
|
nb := sql.NullBool{}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
nb.Bool = v
|
||||||
|
nb.Valid = true
|
||||||
|
case int64:
|
||||||
|
nb.Bool = v != 0
|
||||||
|
nb.Valid = true
|
||||||
|
case float64:
|
||||||
|
nb.Bool = v != 0
|
||||||
|
nb.Valid = true
|
||||||
|
}
|
||||||
|
field.Set(reflect.ValueOf(nb))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Support sql.NullFloat64
|
||||||
|
if field.Type() == reflect.TypeOf(sql.NullFloat64{}) {
|
||||||
|
nf := sql.NullFloat64{}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case float64:
|
||||||
|
nf.Float64 = v
|
||||||
|
nf.Valid = true
|
||||||
|
case int64:
|
||||||
|
nf.Float64 = float64(v)
|
||||||
|
nf.Valid = true
|
||||||
|
}
|
||||||
|
field.Set(reflect.ValueOf(nf))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fallthrough
|
fallthrough
|
||||||
default:
|
default:
|
||||||
// Not supported yet
|
// Not supported yet
|
||||||
|
|||||||
@ -1061,61 +1061,72 @@ func (r *RQLiteManager) recoverFromSplitBrain(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Clear our Raft state if peers have more recent data
|
// Step 4: Only clear Raft state if this is a completely new node
|
||||||
|
// CRITICAL: Do NOT clear state for nodes that have existing data
|
||||||
|
// Raft will handle catch-up automatically via log replication or snapshot installation
|
||||||
ourIndex := r.getRaftLogIndex()
|
ourIndex := r.getRaftLogIndex()
|
||||||
if maxPeerIndex > ourIndex || (maxPeerIndex == 0 && ourIndex == 0) {
|
|
||||||
r.logger.Info("Clearing Raft state to allow clean cluster join",
|
// Only clear state for truly new nodes (log index 0) joining an existing cluster
|
||||||
|
// This is the only safe automatic recovery - all other cases should let Raft handle it
|
||||||
|
isNewNode := ourIndex == 0 && maxPeerIndex > 0
|
||||||
|
|
||||||
|
if !isNewNode {
|
||||||
|
r.logger.Info("Split-brain recovery: node has existing data, letting Raft handle catch-up",
|
||||||
zap.Uint64("our_index", ourIndex),
|
zap.Uint64("our_index", ourIndex),
|
||||||
zap.Uint64("peer_max_index", maxPeerIndex))
|
zap.Uint64("peer_max_index", maxPeerIndex),
|
||||||
|
zap.String("action", "skipping state clear - Raft will sync automatically"))
|
||||||
if err := r.clearRaftState(rqliteDataDir); err != nil {
|
|
||||||
return fmt.Errorf("failed to clear Raft state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Refresh peer metadata and force write peers.json
|
|
||||||
// We trigger peer exchange again to ensure we have the absolute latest metadata
|
|
||||||
// after clearing state, then force write peers.json regardless of changes
|
|
||||||
r.logger.Info("Refreshing peer metadata after clearing raft state")
|
|
||||||
r.discoveryService.TriggerPeerExchange(ctx)
|
|
||||||
time.Sleep(1 * time.Second) // Brief wait for peer exchange to complete
|
|
||||||
|
|
||||||
r.logger.Info("Force writing peers.json with all discovered peers")
|
|
||||||
// We use ForceWritePeersJSON instead of TriggerSync because TriggerSync
|
|
||||||
// only writes if membership changed, but after clearing state we need
|
|
||||||
// to write regardless of changes
|
|
||||||
if err := r.discoveryService.ForceWritePeersJSON(); err != nil {
|
|
||||||
return fmt.Errorf("failed to force write peers.json: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify peers.json was created
|
|
||||||
peersPath := filepath.Join(rqliteDataDir, "raft", "peers.json")
|
|
||||||
if _, err := os.Stat(peersPath); err != nil {
|
|
||||||
return fmt.Errorf("peers.json not created after force write: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logger.Info("peers.json verified after force write",
|
|
||||||
zap.String("peers_path", peersPath))
|
|
||||||
|
|
||||||
// Step 6: Restart RQLite to pick up new peers.json
|
|
||||||
r.logger.Info("Restarting RQLite to apply new cluster configuration")
|
|
||||||
if err := r.recoverCluster(ctx, peersPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to restart RQLite: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Wait for cluster to form (waitForReadyAndConnect already handled readiness)
|
|
||||||
r.logger.Info("Waiting for cluster to stabilize after recovery...")
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
|
|
||||||
// Verify recovery succeeded
|
|
||||||
if r.isInSplitBrainState() {
|
|
||||||
return fmt.Errorf("still in split-brain after recovery attempt")
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logger.Info("Split-brain recovery completed successfully")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("cannot recover: we have more recent data than peers")
|
r.logger.Info("Split-brain recovery: new node joining cluster - clearing state",
|
||||||
|
zap.Uint64("our_index", ourIndex),
|
||||||
|
zap.Uint64("peer_max_index", maxPeerIndex))
|
||||||
|
|
||||||
|
if err := r.clearRaftState(rqliteDataDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to clear Raft state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Refresh peer metadata and force write peers.json
|
||||||
|
// We trigger peer exchange again to ensure we have the absolute latest metadata
|
||||||
|
// after clearing state, then force write peers.json regardless of changes
|
||||||
|
r.logger.Info("Refreshing peer metadata after clearing raft state")
|
||||||
|
r.discoveryService.TriggerPeerExchange(ctx)
|
||||||
|
time.Sleep(1 * time.Second) // Brief wait for peer exchange to complete
|
||||||
|
|
||||||
|
r.logger.Info("Force writing peers.json with all discovered peers")
|
||||||
|
// We use ForceWritePeersJSON instead of TriggerSync because TriggerSync
|
||||||
|
// only writes if membership changed, but after clearing state we need
|
||||||
|
// to write regardless of changes
|
||||||
|
if err := r.discoveryService.ForceWritePeersJSON(); err != nil {
|
||||||
|
return fmt.Errorf("failed to force write peers.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify peers.json was created
|
||||||
|
peersPath := filepath.Join(rqliteDataDir, "raft", "peers.json")
|
||||||
|
if _, err := os.Stat(peersPath); err != nil {
|
||||||
|
return fmt.Errorf("peers.json not created after force write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("peers.json verified after force write",
|
||||||
|
zap.String("peers_path", peersPath))
|
||||||
|
|
||||||
|
// Step 6: Restart RQLite to pick up new peers.json
|
||||||
|
r.logger.Info("Restarting RQLite to apply new cluster configuration")
|
||||||
|
if err := r.recoverCluster(ctx, peersPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to restart RQLite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Wait for cluster to form (waitForReadyAndConnect already handled readiness)
|
||||||
|
r.logger.Info("Waiting for cluster to stabilize after recovery...")
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
// Verify recovery succeeded
|
||||||
|
if r.isInSplitBrainState() {
|
||||||
|
return fmt.Errorf("still in split-brain after recovery attempt")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Split-brain recovery completed successfully")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSafeToClearState verifies we can safely clear Raft state
|
// isSafeToClearState verifies we can safely clear Raft state
|
||||||
@ -1216,11 +1227,16 @@ func (r *RQLiteManager) performPreStartClusterDiscovery(ctx context.Context, rql
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AUTOMATIC RECOVERY: Check if we have stale Raft state that conflicts with cluster
|
// AUTOMATIC RECOVERY: Check if we have stale Raft state that conflicts with cluster
|
||||||
// If we have existing state but peers have higher log indexes, clear our state to allow clean join
|
// Only clear state if we are a NEW node joining an EXISTING cluster with higher log indexes
|
||||||
|
// CRITICAL FIX: Do NOT clear state if our log index is the same or similar to peers
|
||||||
|
// This prevents data loss during normal cluster restarts
|
||||||
allPeers := r.discoveryService.GetAllPeers()
|
allPeers := r.discoveryService.GetAllPeers()
|
||||||
hasExistingState := r.hasExistingRaftState(rqliteDataDir)
|
hasExistingState := r.hasExistingRaftState(rqliteDataDir)
|
||||||
|
|
||||||
if hasExistingState {
|
if hasExistingState {
|
||||||
|
// Get our own log index from persisted snapshots
|
||||||
|
ourLogIndex := r.getRaftLogIndex()
|
||||||
|
|
||||||
// Find the highest log index among other peers (excluding ourselves)
|
// Find the highest log index among other peers (excluding ourselves)
|
||||||
maxPeerIndex := uint64(0)
|
maxPeerIndex := uint64(0)
|
||||||
for _, peer := range allPeers {
|
for _, peer := range allPeers {
|
||||||
@ -1233,25 +1249,43 @@ func (r *RQLiteManager) performPreStartClusterDiscovery(ctx context.Context, rql
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If peers have meaningful log history (> 0) and we have stale state, clear it
|
r.logger.Info("Comparing local state with cluster state",
|
||||||
// This handles the case where we're starting with old state but the cluster has moved on
|
zap.Uint64("our_log_index", ourLogIndex),
|
||||||
if maxPeerIndex > 0 {
|
zap.Uint64("peer_max_log_index", maxPeerIndex),
|
||||||
r.logger.Warn("Detected stale Raft state - clearing to allow clean cluster join",
|
zap.String("data_dir", rqliteDataDir))
|
||||||
|
|
||||||
|
// CRITICAL FIX: Only clear state if this is a COMPLETELY NEW node joining an existing cluster
|
||||||
|
// - New node: our log index is 0, but peers have data (log index > 0)
|
||||||
|
// - For all other cases: let Raft handle catch-up via log replication or snapshot installation
|
||||||
|
//
|
||||||
|
// WHY THIS IS SAFE:
|
||||||
|
// - Raft protocol automatically catches up nodes that are behind via AppendEntries
|
||||||
|
// - If a node is too far behind, the leader will send a snapshot
|
||||||
|
// - We should NEVER clear state for nodes that have existing data, even if they're behind
|
||||||
|
// - This prevents data loss during cluster restarts and rolling upgrades
|
||||||
|
isNewNodeJoiningCluster := ourLogIndex == 0 && maxPeerIndex > 0
|
||||||
|
|
||||||
|
if isNewNodeJoiningCluster {
|
||||||
|
r.logger.Warn("New node joining existing cluster - clearing local state to allow clean join",
|
||||||
|
zap.Uint64("our_log_index", ourLogIndex),
|
||||||
zap.Uint64("peer_max_log_index", maxPeerIndex),
|
zap.Uint64("peer_max_log_index", maxPeerIndex),
|
||||||
zap.String("data_dir", rqliteDataDir))
|
zap.String("data_dir", rqliteDataDir))
|
||||||
|
|
||||||
if err := r.clearRaftState(rqliteDataDir); err != nil {
|
if err := r.clearRaftState(rqliteDataDir); err != nil {
|
||||||
r.logger.Error("Failed to clear Raft state", zap.Error(err))
|
r.logger.Error("Failed to clear Raft state", zap.Error(err))
|
||||||
// Continue anyway - rqlite might still be able to recover
|
|
||||||
} else {
|
} else {
|
||||||
// Force write peers.json after clearing stale state
|
// Force write peers.json after clearing state
|
||||||
if r.discoveryService != nil {
|
if r.discoveryService != nil {
|
||||||
r.logger.Info("Force writing peers.json after clearing stale Raft state")
|
r.logger.Info("Force writing peers.json after clearing local state")
|
||||||
if err := r.discoveryService.ForceWritePeersJSON(); err != nil {
|
if err := r.discoveryService.ForceWritePeersJSON(); err != nil {
|
||||||
r.logger.Error("Failed to force write peers.json after clearing stale state", zap.Error(err))
|
r.logger.Error("Failed to force write peers.json after clearing state", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
r.logger.Info("Preserving Raft state - node will catch up via Raft protocol",
|
||||||
|
zap.Uint64("our_log_index", ourLogIndex),
|
||||||
|
zap.Uint64("peer_max_log_index", maxPeerIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
187
pkg/serverless/config.go
Normal file
187
pkg/serverless/config.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds configuration for the serverless engine.
|
||||||
|
type Config struct {
|
||||||
|
// Memory limits
|
||||||
|
DefaultMemoryLimitMB int `yaml:"default_memory_limit_mb"`
|
||||||
|
MaxMemoryLimitMB int `yaml:"max_memory_limit_mb"`
|
||||||
|
|
||||||
|
// Execution limits
|
||||||
|
DefaultTimeoutSeconds int `yaml:"default_timeout_seconds"`
|
||||||
|
MaxTimeoutSeconds int `yaml:"max_timeout_seconds"`
|
||||||
|
|
||||||
|
// Retry configuration
|
||||||
|
DefaultRetryCount int `yaml:"default_retry_count"`
|
||||||
|
MaxRetryCount int `yaml:"max_retry_count"`
|
||||||
|
DefaultRetryDelaySeconds int `yaml:"default_retry_delay_seconds"`
|
||||||
|
|
||||||
|
// Rate limiting (global)
|
||||||
|
GlobalRateLimitPerMinute int `yaml:"global_rate_limit_per_minute"`
|
||||||
|
|
||||||
|
// Background job configuration
|
||||||
|
JobWorkers int `yaml:"job_workers"`
|
||||||
|
JobPollInterval time.Duration `yaml:"job_poll_interval"`
|
||||||
|
JobMaxQueueSize int `yaml:"job_max_queue_size"`
|
||||||
|
JobMaxPayloadSize int `yaml:"job_max_payload_size"` // bytes
|
||||||
|
|
||||||
|
// Scheduler configuration
|
||||||
|
CronPollInterval time.Duration `yaml:"cron_poll_interval"`
|
||||||
|
TimerPollInterval time.Duration `yaml:"timer_poll_interval"`
|
||||||
|
DBPollInterval time.Duration `yaml:"db_poll_interval"`
|
||||||
|
|
||||||
|
// WASM compilation cache
|
||||||
|
ModuleCacheSize int `yaml:"module_cache_size"` // Number of compiled modules to cache
|
||||||
|
EnablePrewarm bool `yaml:"enable_prewarm"` // Pre-compile frequently used functions
|
||||||
|
|
||||||
|
// Secrets encryption
|
||||||
|
SecretsEncryptionKey string `yaml:"secrets_encryption_key"` // AES-256 key (32 bytes, hex-encoded)
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
LogInvocations bool `yaml:"log_invocations"` // Log all invocations to database
|
||||||
|
LogRetention int `yaml:"log_retention"` // Days to retain logs
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a configuration with sensible defaults.
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
// Memory limits
|
||||||
|
DefaultMemoryLimitMB: 64,
|
||||||
|
MaxMemoryLimitMB: 256,
|
||||||
|
|
||||||
|
// Execution limits
|
||||||
|
DefaultTimeoutSeconds: 30,
|
||||||
|
MaxTimeoutSeconds: 300, // 5 minutes max
|
||||||
|
|
||||||
|
// Retry configuration
|
||||||
|
DefaultRetryCount: 0,
|
||||||
|
MaxRetryCount: 5,
|
||||||
|
DefaultRetryDelaySeconds: 5,
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
GlobalRateLimitPerMinute: 10000, // 10k requests/minute globally
|
||||||
|
|
||||||
|
// Background jobs
|
||||||
|
JobWorkers: 4,
|
||||||
|
JobPollInterval: time.Second,
|
||||||
|
JobMaxQueueSize: 10000,
|
||||||
|
JobMaxPayloadSize: 1024 * 1024, // 1MB
|
||||||
|
|
||||||
|
// Scheduler
|
||||||
|
CronPollInterval: time.Minute,
|
||||||
|
TimerPollInterval: time.Second,
|
||||||
|
DBPollInterval: time.Second * 5,
|
||||||
|
|
||||||
|
// WASM cache
|
||||||
|
ModuleCacheSize: 100,
|
||||||
|
EnablePrewarm: true,
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
LogInvocations: true,
|
||||||
|
LogRetention: 7, // 7 days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the configuration for errors.
|
||||||
|
func (c *Config) Validate() []error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if c.DefaultMemoryLimitMB <= 0 {
|
||||||
|
errs = append(errs, &ConfigError{Field: "DefaultMemoryLimitMB", Message: "must be positive"})
|
||||||
|
}
|
||||||
|
if c.MaxMemoryLimitMB < c.DefaultMemoryLimitMB {
|
||||||
|
errs = append(errs, &ConfigError{Field: "MaxMemoryLimitMB", Message: "must be >= DefaultMemoryLimitMB"})
|
||||||
|
}
|
||||||
|
if c.DefaultTimeoutSeconds <= 0 {
|
||||||
|
errs = append(errs, &ConfigError{Field: "DefaultTimeoutSeconds", Message: "must be positive"})
|
||||||
|
}
|
||||||
|
if c.MaxTimeoutSeconds < c.DefaultTimeoutSeconds {
|
||||||
|
errs = append(errs, &ConfigError{Field: "MaxTimeoutSeconds", Message: "must be >= DefaultTimeoutSeconds"})
|
||||||
|
}
|
||||||
|
if c.GlobalRateLimitPerMinute <= 0 {
|
||||||
|
errs = append(errs, &ConfigError{Field: "GlobalRateLimitPerMinute", Message: "must be positive"})
|
||||||
|
}
|
||||||
|
if c.JobWorkers <= 0 {
|
||||||
|
errs = append(errs, &ConfigError{Field: "JobWorkers", Message: "must be positive"})
|
||||||
|
}
|
||||||
|
if c.ModuleCacheSize <= 0 {
|
||||||
|
errs = append(errs, &ConfigError{Field: "ModuleCacheSize", Message: "must be positive"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyDefaults fills in zero values with defaults.
|
||||||
|
func (c *Config) ApplyDefaults() {
|
||||||
|
defaults := DefaultConfig()
|
||||||
|
|
||||||
|
if c.DefaultMemoryLimitMB == 0 {
|
||||||
|
c.DefaultMemoryLimitMB = defaults.DefaultMemoryLimitMB
|
||||||
|
}
|
||||||
|
if c.MaxMemoryLimitMB == 0 {
|
||||||
|
c.MaxMemoryLimitMB = defaults.MaxMemoryLimitMB
|
||||||
|
}
|
||||||
|
if c.DefaultTimeoutSeconds == 0 {
|
||||||
|
c.DefaultTimeoutSeconds = defaults.DefaultTimeoutSeconds
|
||||||
|
}
|
||||||
|
if c.MaxTimeoutSeconds == 0 {
|
||||||
|
c.MaxTimeoutSeconds = defaults.MaxTimeoutSeconds
|
||||||
|
}
|
||||||
|
if c.GlobalRateLimitPerMinute == 0 {
|
||||||
|
c.GlobalRateLimitPerMinute = defaults.GlobalRateLimitPerMinute
|
||||||
|
}
|
||||||
|
if c.JobWorkers == 0 {
|
||||||
|
c.JobWorkers = defaults.JobWorkers
|
||||||
|
}
|
||||||
|
if c.JobPollInterval == 0 {
|
||||||
|
c.JobPollInterval = defaults.JobPollInterval
|
||||||
|
}
|
||||||
|
if c.JobMaxQueueSize == 0 {
|
||||||
|
c.JobMaxQueueSize = defaults.JobMaxQueueSize
|
||||||
|
}
|
||||||
|
if c.JobMaxPayloadSize == 0 {
|
||||||
|
c.JobMaxPayloadSize = defaults.JobMaxPayloadSize
|
||||||
|
}
|
||||||
|
if c.CronPollInterval == 0 {
|
||||||
|
c.CronPollInterval = defaults.CronPollInterval
|
||||||
|
}
|
||||||
|
if c.TimerPollInterval == 0 {
|
||||||
|
c.TimerPollInterval = defaults.TimerPollInterval
|
||||||
|
}
|
||||||
|
if c.DBPollInterval == 0 {
|
||||||
|
c.DBPollInterval = defaults.DBPollInterval
|
||||||
|
}
|
||||||
|
if c.ModuleCacheSize == 0 {
|
||||||
|
c.ModuleCacheSize = defaults.ModuleCacheSize
|
||||||
|
}
|
||||||
|
if c.LogRetention == 0 {
|
||||||
|
c.LogRetention = defaults.LogRetention
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMemoryLimit returns a copy with the memory limit set.
|
||||||
|
func (c *Config) WithMemoryLimit(defaultMB, maxMB int) *Config {
|
||||||
|
copy := *c
|
||||||
|
copy.DefaultMemoryLimitMB = defaultMB
|
||||||
|
copy.MaxMemoryLimitMB = maxMB
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTimeout returns a copy with the timeout set.
|
||||||
|
func (c *Config) WithTimeout(defaultSec, maxSec int) *Config {
|
||||||
|
copy := *c
|
||||||
|
copy.DefaultTimeoutSeconds = defaultSec
|
||||||
|
copy.MaxTimeoutSeconds = maxSec
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRateLimit returns a copy with the rate limit set.
|
||||||
|
func (c *Config) WithRateLimit(perMinute int) *Config {
|
||||||
|
copy := *c
|
||||||
|
copy.GlobalRateLimitPerMinute = perMinute
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
|
||||||
458
pkg/serverless/engine.go
Normal file
458
pkg/serverless/engine.go
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/tetratelabs/wazero"
|
||||||
|
"github.com/tetratelabs/wazero/api"
|
||||||
|
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure Engine implements FunctionExecutor interface.
|
||||||
|
var _ FunctionExecutor = (*Engine)(nil)
|
||||||
|
|
||||||
|
// Engine is the core WASM execution engine using wazero.
|
||||||
|
// It manages compiled module caching and function execution.
|
||||||
|
type Engine struct {
|
||||||
|
runtime wazero.Runtime
|
||||||
|
config *Config
|
||||||
|
registry FunctionRegistry
|
||||||
|
hostServices HostServices
|
||||||
|
logger *zap.Logger
|
||||||
|
|
||||||
|
// Module cache: wasmCID -> compiled module
|
||||||
|
moduleCache map[string]wazero.CompiledModule
|
||||||
|
moduleCacheMu sync.RWMutex
|
||||||
|
|
||||||
|
// Invocation logger for metrics/debugging
|
||||||
|
invocationLogger InvocationLogger
|
||||||
|
|
||||||
|
// Rate limiter
|
||||||
|
rateLimiter RateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvocationLogger logs function invocations (optional).
|
||||||
|
type InvocationLogger interface {
|
||||||
|
Log(ctx context.Context, inv *InvocationRecord) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvocationRecord represents a logged invocation.
|
||||||
|
type InvocationRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
CallerWallet string `json:"caller_wallet,omitempty"`
|
||||||
|
InputSize int `json:"input_size"`
|
||||||
|
OutputSize int `json:"output_size"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
|
DurationMS int64 `json:"duration_ms"`
|
||||||
|
Status InvocationStatus `json:"status"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
MemoryUsedMB float64 `json:"memory_used_mb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter checks if a request should be rate limited.
|
||||||
|
type RateLimiter interface {
|
||||||
|
Allow(ctx context.Context, key string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineOption configures the Engine.
|
||||||
|
type EngineOption func(*Engine)
|
||||||
|
|
||||||
|
// WithInvocationLogger sets the invocation logger.
|
||||||
|
func WithInvocationLogger(logger InvocationLogger) EngineOption {
|
||||||
|
return func(e *Engine) {
|
||||||
|
e.invocationLogger = logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRateLimiter sets the rate limiter.
|
||||||
|
func WithRateLimiter(limiter RateLimiter) EngineOption {
|
||||||
|
return func(e *Engine) {
|
||||||
|
e.rateLimiter = limiter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine creates a new WASM execution engine.
|
||||||
|
func NewEngine(cfg *Config, registry FunctionRegistry, hostServices HostServices, logger *zap.Logger, opts ...EngineOption) (*Engine, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = DefaultConfig()
|
||||||
|
}
|
||||||
|
cfg.ApplyDefaults()
|
||||||
|
|
||||||
|
// Create wazero runtime with compilation cache
|
||||||
|
runtimeConfig := wazero.NewRuntimeConfig().
|
||||||
|
WithCloseOnContextDone(true)
|
||||||
|
|
||||||
|
runtime := wazero.NewRuntimeWithConfig(context.Background(), runtimeConfig)
|
||||||
|
|
||||||
|
// Instantiate WASI - required for WASM modules compiled with TinyGo targeting WASI
|
||||||
|
wasi_snapshot_preview1.MustInstantiate(context.Background(), runtime)
|
||||||
|
|
||||||
|
engine := &Engine{
|
||||||
|
runtime: runtime,
|
||||||
|
config: cfg,
|
||||||
|
registry: registry,
|
||||||
|
hostServices: hostServices,
|
||||||
|
logger: logger,
|
||||||
|
moduleCache: make(map[string]wazero.CompiledModule),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply options
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
return engine, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs a function with the given input and returns the output.
|
||||||
|
func (e *Engine) Execute(ctx context.Context, fn *Function, input []byte, invCtx *InvocationContext) ([]byte, error) {
|
||||||
|
if fn == nil {
|
||||||
|
return nil, &ValidationError{Field: "function", Message: "cannot be nil"}
|
||||||
|
}
|
||||||
|
if invCtx == nil {
|
||||||
|
invCtx = &InvocationContext{
|
||||||
|
RequestID: uuid.New().String(),
|
||||||
|
FunctionID: fn.ID,
|
||||||
|
FunctionName: fn.Name,
|
||||||
|
Namespace: fn.Namespace,
|
||||||
|
TriggerType: TriggerTypeHTTP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
if e.rateLimiter != nil {
|
||||||
|
allowed, err := e.rateLimiter.Allow(ctx, "global")
|
||||||
|
if err != nil {
|
||||||
|
e.logger.Warn("Rate limiter error", zap.Error(err))
|
||||||
|
} else if !allowed {
|
||||||
|
return nil, ErrRateLimited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create timeout context
|
||||||
|
timeout := time.Duration(fn.TimeoutSeconds) * time.Second
|
||||||
|
if timeout > time.Duration(e.config.MaxTimeoutSeconds)*time.Second {
|
||||||
|
timeout = time.Duration(e.config.MaxTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
execCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get compiled module (from cache or compile)
|
||||||
|
module, err := e.getOrCompileModule(execCtx, fn.WASMCID)
|
||||||
|
if err != nil {
|
||||||
|
e.logInvocation(ctx, fn, invCtx, startTime, 0, InvocationStatusError, err)
|
||||||
|
return nil, &ExecutionError{FunctionName: fn.Name, RequestID: invCtx.RequestID, Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the module
|
||||||
|
output, err := e.executeModule(execCtx, module, fn, input, invCtx)
|
||||||
|
if err != nil {
|
||||||
|
status := InvocationStatusError
|
||||||
|
if execCtx.Err() == context.DeadlineExceeded {
|
||||||
|
status = InvocationStatusTimeout
|
||||||
|
err = ErrTimeout
|
||||||
|
}
|
||||||
|
e.logInvocation(ctx, fn, invCtx, startTime, len(output), status, err)
|
||||||
|
return nil, &ExecutionError{FunctionName: fn.Name, RequestID: invCtx.RequestID, Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.logInvocation(ctx, fn, invCtx, startTime, len(output), InvocationStatusSuccess, nil)
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompile compiles a WASM module and caches it for faster execution.
|
||||||
|
func (e *Engine) Precompile(ctx context.Context, wasmCID string, wasmBytes []byte) error {
|
||||||
|
if wasmCID == "" {
|
||||||
|
return &ValidationError{Field: "wasmCID", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
if len(wasmBytes) == 0 {
|
||||||
|
return &ValidationError{Field: "wasmBytes", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already cached
|
||||||
|
e.moduleCacheMu.RLock()
|
||||||
|
_, exists := e.moduleCache[wasmCID]
|
||||||
|
e.moduleCacheMu.RUnlock()
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile the module
|
||||||
|
compiled, err := e.runtime.CompileModule(ctx, wasmBytes)
|
||||||
|
if err != nil {
|
||||||
|
return &DeployError{FunctionName: wasmCID, Cause: fmt.Errorf("failed to compile WASM: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the compiled module
|
||||||
|
e.moduleCacheMu.Lock()
|
||||||
|
defer e.moduleCacheMu.Unlock()
|
||||||
|
|
||||||
|
// Evict oldest if cache is full
|
||||||
|
if len(e.moduleCache) >= e.config.ModuleCacheSize {
|
||||||
|
e.evictOldestModule()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.moduleCache[wasmCID] = compiled
|
||||||
|
|
||||||
|
e.logger.Debug("Module precompiled and cached",
|
||||||
|
zap.String("wasm_cid", wasmCID),
|
||||||
|
zap.Int("cache_size", len(e.moduleCache)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate removes a compiled module from the cache.
|
||||||
|
func (e *Engine) Invalidate(wasmCID string) {
|
||||||
|
e.moduleCacheMu.Lock()
|
||||||
|
defer e.moduleCacheMu.Unlock()
|
||||||
|
|
||||||
|
if module, exists := e.moduleCache[wasmCID]; exists {
|
||||||
|
_ = module.Close(context.Background())
|
||||||
|
delete(e.moduleCache, wasmCID)
|
||||||
|
e.logger.Debug("Module invalidated from cache", zap.String("wasm_cid", wasmCID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the engine and releases resources.
|
||||||
|
func (e *Engine) Close(ctx context.Context) error {
|
||||||
|
e.moduleCacheMu.Lock()
|
||||||
|
defer e.moduleCacheMu.Unlock()
|
||||||
|
|
||||||
|
// Close all cached modules
|
||||||
|
for cid, module := range e.moduleCache {
|
||||||
|
if err := module.Close(ctx); err != nil {
|
||||||
|
e.logger.Warn("Failed to close cached module", zap.String("cid", cid), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.moduleCache = make(map[string]wazero.CompiledModule)
|
||||||
|
|
||||||
|
// Close the runtime
|
||||||
|
return e.runtime.Close(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheStats returns cache statistics.
|
||||||
|
func (e *Engine) GetCacheStats() (size int, capacity int) {
|
||||||
|
e.moduleCacheMu.RLock()
|
||||||
|
defer e.moduleCacheMu.RUnlock()
|
||||||
|
return len(e.moduleCache), e.config.ModuleCacheSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Private methods
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// getOrCompileModule retrieves a compiled module from cache or compiles it.
|
||||||
|
func (e *Engine) getOrCompileModule(ctx context.Context, wasmCID string) (wazero.CompiledModule, error) {
|
||||||
|
// Check cache first
|
||||||
|
e.moduleCacheMu.RLock()
|
||||||
|
if module, exists := e.moduleCache[wasmCID]; exists {
|
||||||
|
e.moduleCacheMu.RUnlock()
|
||||||
|
return module, nil
|
||||||
|
}
|
||||||
|
e.moduleCacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch WASM bytes from registry
|
||||||
|
wasmBytes, err := e.registry.GetWASMBytes(ctx, wasmCID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch WASM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile the module
|
||||||
|
compiled, err := e.runtime.CompileModule(ctx, wasmBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrCompilationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the compiled module
|
||||||
|
e.moduleCacheMu.Lock()
|
||||||
|
defer e.moduleCacheMu.Unlock()
|
||||||
|
|
||||||
|
// Double-check (another goroutine might have added it)
|
||||||
|
if existingModule, exists := e.moduleCache[wasmCID]; exists {
|
||||||
|
_ = compiled.Close(ctx) // Discard our compilation
|
||||||
|
return existingModule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict if cache is full
|
||||||
|
if len(e.moduleCache) >= e.config.ModuleCacheSize {
|
||||||
|
e.evictOldestModule()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.moduleCache[wasmCID] = compiled
|
||||||
|
|
||||||
|
e.logger.Debug("Module compiled and cached",
|
||||||
|
zap.String("wasm_cid", wasmCID),
|
||||||
|
zap.Int("cache_size", len(e.moduleCache)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return compiled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeModule instantiates and runs a WASM module.
|
||||||
|
func (e *Engine) executeModule(ctx context.Context, compiled wazero.CompiledModule, fn *Function, input []byte, invCtx *InvocationContext) ([]byte, error) {
|
||||||
|
// Create buffers for stdin/stdout (WASI uses these for I/O)
|
||||||
|
stdin := bytes.NewReader(input)
|
||||||
|
stdout := new(bytes.Buffer)
|
||||||
|
stderr := new(bytes.Buffer)
|
||||||
|
|
||||||
|
// Create module configuration with WASI stdio
|
||||||
|
moduleConfig := wazero.NewModuleConfig().
|
||||||
|
WithName(fn.Name).
|
||||||
|
WithStdin(stdin).
|
||||||
|
WithStdout(stdout).
|
||||||
|
WithStderr(stderr).
|
||||||
|
WithArgs(fn.Name) // argv[0] is the program name
|
||||||
|
|
||||||
|
// Instantiate and run the module (WASI _start will be called automatically)
|
||||||
|
instance, err := e.runtime.InstantiateModule(ctx, compiled, moduleConfig)
|
||||||
|
if err != nil {
|
||||||
|
// Check if stderr has any output
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
e.logger.Warn("WASM stderr output", zap.String("stderr", stderr.String()))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to instantiate module: %w", err)
|
||||||
|
}
|
||||||
|
defer instance.Close(ctx)
|
||||||
|
|
||||||
|
// For WASI modules, the output is already in stdout buffer
|
||||||
|
// The _start function was called during instantiation
|
||||||
|
output := stdout.Bytes()
|
||||||
|
|
||||||
|
// Log stderr if any
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
e.logger.Debug("WASM stderr", zap.String("stderr", stderr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callHandleFunction calls the main 'handle' export in the WASM module.
|
||||||
|
func (e *Engine) callHandleFunction(ctx context.Context, instance api.Module, input []byte, invCtx *InvocationContext) ([]byte, error) {
|
||||||
|
// Get the 'handle' function export
|
||||||
|
handleFn := instance.ExportedFunction("handle")
|
||||||
|
if handleFn == nil {
|
||||||
|
return nil, fmt.Errorf("WASM module does not export 'handle' function")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get memory export
|
||||||
|
memory := instance.ExportedMemory("memory")
|
||||||
|
if memory == nil {
|
||||||
|
return nil, fmt.Errorf("WASM module does not export 'memory'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get malloc/free exports for memory management
|
||||||
|
mallocFn := instance.ExportedFunction("malloc")
|
||||||
|
freeFn := instance.ExportedFunction("free")
|
||||||
|
|
||||||
|
var inputPtr uint32
|
||||||
|
var inputLen = uint32(len(input))
|
||||||
|
|
||||||
|
if mallocFn != nil && len(input) > 0 {
|
||||||
|
// Allocate memory for input
|
||||||
|
results, err := mallocFn.Call(ctx, uint64(inputLen))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("malloc failed: %w", err)
|
||||||
|
}
|
||||||
|
inputPtr = uint32(results[0])
|
||||||
|
|
||||||
|
// Write input to memory
|
||||||
|
if !memory.Write(inputPtr, input) {
|
||||||
|
return nil, fmt.Errorf("failed to write input to WASM memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer free if available
|
||||||
|
if freeFn != nil {
|
||||||
|
defer func() {
|
||||||
|
_, _ = freeFn.Call(ctx, uint64(inputPtr))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call handle(input_ptr, input_len)
|
||||||
|
// Returns: output_ptr (packed with length in upper 32 bits)
|
||||||
|
results, err := handleFn.Call(ctx, uint64(inputPtr), uint64(inputLen))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("handle function error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, nil // No output
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse result - assume format: lower 32 bits = ptr, upper 32 bits = len
|
||||||
|
result := results[0]
|
||||||
|
outputPtr := uint32(result & 0xFFFFFFFF)
|
||||||
|
outputLen := uint32(result >> 32)
|
||||||
|
|
||||||
|
if outputLen == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read output from memory
|
||||||
|
output, ok := memory.Read(outputPtr, outputLen)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("failed to read output from WASM memory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a copy (memory will be freed)
|
||||||
|
outputCopy := make([]byte, len(output))
|
||||||
|
copy(outputCopy, output)
|
||||||
|
|
||||||
|
return outputCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictOldestModule removes the oldest module from cache.
|
||||||
|
// Must be called with moduleCacheMu held.
|
||||||
|
func (e *Engine) evictOldestModule() {
|
||||||
|
// Simple LRU: just remove the first one we find
|
||||||
|
// In production, you'd want proper LRU tracking
|
||||||
|
for cid, module := range e.moduleCache {
|
||||||
|
_ = module.Close(context.Background())
|
||||||
|
delete(e.moduleCache, cid)
|
||||||
|
e.logger.Debug("Evicted module from cache", zap.String("wasm_cid", cid))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logInvocation logs an invocation record.
|
||||||
|
func (e *Engine) logInvocation(ctx context.Context, fn *Function, invCtx *InvocationContext, startTime time.Time, outputSize int, status InvocationStatus, err error) {
|
||||||
|
if e.invocationLogger == nil || !e.config.LogInvocations {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completedAt := time.Now()
|
||||||
|
record := &InvocationRecord{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
FunctionID: fn.ID,
|
||||||
|
RequestID: invCtx.RequestID,
|
||||||
|
TriggerType: invCtx.TriggerType,
|
||||||
|
CallerWallet: invCtx.CallerWallet,
|
||||||
|
OutputSize: outputSize,
|
||||||
|
StartedAt: startTime,
|
||||||
|
CompletedAt: completedAt,
|
||||||
|
DurationMS: completedAt.Sub(startTime).Milliseconds(),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
record.ErrorMessage = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if logErr := e.invocationLogger.Log(ctx, record); logErr != nil {
|
||||||
|
e.logger.Warn("Failed to log invocation", zap.Error(logErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
212
pkg/serverless/errors.go
Normal file
212
pkg/serverless/errors.go
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for common conditions.
|
||||||
|
var (
|
||||||
|
// ErrFunctionNotFound is returned when a function does not exist.
|
||||||
|
ErrFunctionNotFound = errors.New("function not found")
|
||||||
|
|
||||||
|
// ErrFunctionExists is returned when attempting to create a function that already exists.
|
||||||
|
ErrFunctionExists = errors.New("function already exists")
|
||||||
|
|
||||||
|
// ErrVersionNotFound is returned when a specific function version does not exist.
|
||||||
|
ErrVersionNotFound = errors.New("function version not found")
|
||||||
|
|
||||||
|
// ErrSecretNotFound is returned when a secret does not exist.
|
||||||
|
ErrSecretNotFound = errors.New("secret not found")
|
||||||
|
|
||||||
|
// ErrJobNotFound is returned when a job does not exist.
|
||||||
|
ErrJobNotFound = errors.New("job not found")
|
||||||
|
|
||||||
|
// ErrTriggerNotFound is returned when a trigger does not exist.
|
||||||
|
ErrTriggerNotFound = errors.New("trigger not found")
|
||||||
|
|
||||||
|
// ErrTimerNotFound is returned when a timer does not exist.
|
||||||
|
ErrTimerNotFound = errors.New("timer not found")
|
||||||
|
|
||||||
|
// ErrUnauthorized is returned when the caller is not authorized.
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
|
// ErrRateLimited is returned when the rate limit is exceeded.
|
||||||
|
ErrRateLimited = errors.New("rate limit exceeded")
|
||||||
|
|
||||||
|
// ErrInvalidWASM is returned when the WASM module is invalid.
|
||||||
|
ErrInvalidWASM = errors.New("invalid WASM module")
|
||||||
|
|
||||||
|
// ErrCompilationFailed is returned when WASM compilation fails.
|
||||||
|
ErrCompilationFailed = errors.New("WASM compilation failed")
|
||||||
|
|
||||||
|
// ErrExecutionFailed is returned when function execution fails.
|
||||||
|
ErrExecutionFailed = errors.New("function execution failed")
|
||||||
|
|
||||||
|
// ErrTimeout is returned when function execution times out.
|
||||||
|
ErrTimeout = errors.New("function execution timeout")
|
||||||
|
|
||||||
|
// ErrMemoryExceeded is returned when the function exceeds memory limits.
|
||||||
|
ErrMemoryExceeded = errors.New("memory limit exceeded")
|
||||||
|
|
||||||
|
// ErrInvalidInput is returned when function input is invalid.
|
||||||
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
|
|
||||||
|
// ErrWSNotAvailable is returned when WebSocket operations are used outside WS context.
|
||||||
|
ErrWSNotAvailable = errors.New("websocket operations not available in this context")
|
||||||
|
|
||||||
|
// ErrWSClientNotFound is returned when a WebSocket client is not connected.
|
||||||
|
ErrWSClientNotFound = errors.New("websocket client not found")
|
||||||
|
|
||||||
|
// ErrInvalidCronExpression is returned when a cron expression is invalid.
|
||||||
|
ErrInvalidCronExpression = errors.New("invalid cron expression")
|
||||||
|
|
||||||
|
// ErrPayloadTooLarge is returned when a job payload exceeds the maximum size.
|
||||||
|
ErrPayloadTooLarge = errors.New("payload too large")
|
||||||
|
|
||||||
|
// ErrQueueFull is returned when the job queue is full.
|
||||||
|
ErrQueueFull = errors.New("job queue is full")
|
||||||
|
|
||||||
|
// ErrJobCancelled is returned when a job is cancelled.
|
||||||
|
ErrJobCancelled = errors.New("job cancelled")
|
||||||
|
|
||||||
|
// ErrStorageUnavailable is returned when IPFS storage is unavailable.
|
||||||
|
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||||
|
|
||||||
|
// ErrDatabaseUnavailable is returned when the database is unavailable.
|
||||||
|
ErrDatabaseUnavailable = errors.New("database unavailable")
|
||||||
|
|
||||||
|
// ErrCacheUnavailable is returned when the cache is unavailable.
|
||||||
|
ErrCacheUnavailable = errors.New("cache unavailable")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigError represents a configuration validation error.
|
||||||
|
type ConfigError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ConfigError) Error() string {
|
||||||
|
return fmt.Sprintf("config error: %s: %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployError represents an error during function deployment.
|
||||||
|
type DeployError struct {
|
||||||
|
FunctionName string
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DeployError) Error() string {
|
||||||
|
return fmt.Sprintf("deploy error for function '%s': %v", e.FunctionName, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DeployError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionError represents an error during function execution.
|
||||||
|
type ExecutionError struct {
|
||||||
|
FunctionName string
|
||||||
|
RequestID string
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExecutionError) Error() string {
|
||||||
|
return fmt.Sprintf("execution error for function '%s' (request %s): %v",
|
||||||
|
e.FunctionName, e.RequestID, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExecutionError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostFunctionError represents an error in a host function call.
|
||||||
|
type HostFunctionError struct {
|
||||||
|
Function string
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostFunctionError) Error() string {
|
||||||
|
return fmt.Sprintf("host function '%s' error: %v", e.Function, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostFunctionError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerError represents an error in trigger execution.
|
||||||
|
type TriggerError struct {
|
||||||
|
TriggerType string
|
||||||
|
TriggerID string
|
||||||
|
FunctionID string
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TriggerError) Error() string {
|
||||||
|
return fmt.Sprintf("trigger error (%s/%s) for function '%s': %v",
|
||||||
|
e.TriggerType, e.TriggerID, e.FunctionID, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TriggerError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationError represents an input validation error.
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("validation error: %s: %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryableError wraps an error that should be retried.
|
||||||
|
type RetryableError struct {
|
||||||
|
Cause error
|
||||||
|
RetryAfter int // Suggested retry delay in seconds
|
||||||
|
MaxRetries int // Maximum number of retries remaining
|
||||||
|
CurrentTry int // Current attempt number
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RetryableError) Error() string {
|
||||||
|
return fmt.Sprintf("retryable error (attempt %d): %v", e.CurrentTry, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RetryableError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryable checks if an error should be retried.
|
||||||
|
func IsRetryable(err error) bool {
|
||||||
|
var retryable *RetryableError
|
||||||
|
return errors.As(err, &retryable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotFound checks if an error indicates a resource was not found.
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
return errors.Is(err, ErrFunctionNotFound) ||
|
||||||
|
errors.Is(err, ErrVersionNotFound) ||
|
||||||
|
errors.Is(err, ErrSecretNotFound) ||
|
||||||
|
errors.Is(err, ErrJobNotFound) ||
|
||||||
|
errors.Is(err, ErrTriggerNotFound) ||
|
||||||
|
errors.Is(err, ErrTimerNotFound) ||
|
||||||
|
errors.Is(err, ErrWSClientNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsResourceExhausted checks if an error indicates resource exhaustion.
|
||||||
|
func IsResourceExhausted(err error) bool {
|
||||||
|
return errors.Is(err, ErrRateLimited) ||
|
||||||
|
errors.Is(err, ErrMemoryExceeded) ||
|
||||||
|
errors.Is(err, ErrPayloadTooLarge) ||
|
||||||
|
errors.Is(err, ErrQueueFull) ||
|
||||||
|
errors.Is(err, ErrTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsServiceUnavailable checks if an error indicates a service is unavailable.
|
||||||
|
func IsServiceUnavailable(err error) bool {
|
||||||
|
return errors.Is(err, ErrStorageUnavailable) ||
|
||||||
|
errors.Is(err, ErrDatabaseUnavailable) ||
|
||||||
|
errors.Is(err, ErrCacheUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
641
pkg/serverless/hostfuncs.go
Normal file
641
pkg/serverless/hostfuncs.go
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/ipfs"
|
||||||
|
olriclib "github.com/olric-data/olric"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/pubsub"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure HostFunctions implements HostServices interface.
|
||||||
|
var _ HostServices = (*HostFunctions)(nil)
|
||||||
|
|
||||||
|
// HostFunctions provides the bridge between WASM functions and Orama services.
|
||||||
|
// It implements the HostServices interface and is injected into the execution context.
|
||||||
|
type HostFunctions struct {
|
||||||
|
db rqlite.Client
|
||||||
|
cacheClient olriclib.Client
|
||||||
|
storage ipfs.IPFSClient
|
||||||
|
ipfsAPIURL string
|
||||||
|
pubsub *pubsub.ClientAdapter
|
||||||
|
wsManager WebSocketManager
|
||||||
|
secrets SecretsManager
|
||||||
|
httpClient *http.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
|
||||||
|
// Current invocation context (set per-execution)
|
||||||
|
invCtx *InvocationContext
|
||||||
|
invCtxLock sync.RWMutex
|
||||||
|
|
||||||
|
// Captured logs for this invocation
|
||||||
|
logs []LogEntry
|
||||||
|
logsLock sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostFunctionsConfig holds configuration for HostFunctions.
|
||||||
|
type HostFunctionsConfig struct {
|
||||||
|
IPFSAPIURL string
|
||||||
|
HTTPTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHostFunctions creates a new HostFunctions instance.
|
||||||
|
func NewHostFunctions(
|
||||||
|
db rqlite.Client,
|
||||||
|
cacheClient olriclib.Client,
|
||||||
|
storage ipfs.IPFSClient,
|
||||||
|
pubsubAdapter *pubsub.ClientAdapter,
|
||||||
|
wsManager WebSocketManager,
|
||||||
|
secrets SecretsManager,
|
||||||
|
cfg HostFunctionsConfig,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *HostFunctions {
|
||||||
|
httpTimeout := cfg.HTTPTimeout
|
||||||
|
if httpTimeout == 0 {
|
||||||
|
httpTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HostFunctions{
|
||||||
|
db: db,
|
||||||
|
cacheClient: cacheClient,
|
||||||
|
storage: storage,
|
||||||
|
ipfsAPIURL: cfg.IPFSAPIURL,
|
||||||
|
pubsub: pubsubAdapter,
|
||||||
|
wsManager: wsManager,
|
||||||
|
secrets: secrets,
|
||||||
|
httpClient: &http.Client{Timeout: httpTimeout},
|
||||||
|
logger: logger,
|
||||||
|
logs: make([]LogEntry, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInvocationContext sets the current invocation context.
|
||||||
|
// Must be called before executing a function.
|
||||||
|
func (h *HostFunctions) SetInvocationContext(invCtx *InvocationContext) {
|
||||||
|
h.invCtxLock.Lock()
|
||||||
|
defer h.invCtxLock.Unlock()
|
||||||
|
h.invCtx = invCtx
|
||||||
|
h.logs = make([]LogEntry, 0) // Reset logs for new invocation
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogs returns the captured logs for the current invocation.
|
||||||
|
func (h *HostFunctions) GetLogs() []LogEntry {
|
||||||
|
h.logsLock.Lock()
|
||||||
|
defer h.logsLock.Unlock()
|
||||||
|
logsCopy := make([]LogEntry, len(h.logs))
|
||||||
|
copy(logsCopy, h.logs)
|
||||||
|
return logsCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearContext clears the invocation context after execution.
|
||||||
|
func (h *HostFunctions) ClearContext() {
|
||||||
|
h.invCtxLock.Lock()
|
||||||
|
defer h.invCtxLock.Unlock()
|
||||||
|
h.invCtx = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Database Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DBQuery executes a SELECT query and returns JSON-encoded results.
|
||||||
|
func (h *HostFunctions) DBQuery(ctx context.Context, query string, args []interface{}) ([]byte, error) {
|
||||||
|
if h.db == nil {
|
||||||
|
return nil, &HostFunctionError{Function: "db_query", Cause: ErrDatabaseUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []map[string]interface{}
|
||||||
|
if err := h.db.Query(ctx, &results, query, args...); err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "db_query", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "db_query", Cause: fmt.Errorf("failed to marshal results: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBExecute executes an INSERT/UPDATE/DELETE query and returns affected rows.
|
||||||
|
func (h *HostFunctions) DBExecute(ctx context.Context, query string, args []interface{}) (int64, error) {
|
||||||
|
if h.db == nil {
|
||||||
|
return 0, &HostFunctionError{Function: "db_execute", Cause: ErrDatabaseUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.db.Exec(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, &HostFunctionError{Function: "db_execute", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cache Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const cacheDMapName = "serverless_cache"
|
||||||
|
|
||||||
|
// CacheGet retrieves a value from the cache.
|
||||||
|
func (h *HostFunctions) CacheGet(ctx context.Context, key string) ([]byte, error) {
|
||||||
|
if h.cacheClient == nil {
|
||||||
|
return nil, &HostFunctionError{Function: "cache_get", Cause: ErrCacheUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
dm, err := h.cacheClient.NewDMap(cacheDMapName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "cache_get", Cause: fmt.Errorf("failed to get DMap: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := dm.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "cache_get", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := result.Byte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "cache_get", Cause: fmt.Errorf("failed to decode value: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheSet stores a value in the cache with optional TTL.
|
||||||
|
// Note: TTL is currently not supported by the underlying Olric DMap.Put method.
|
||||||
|
// Values are stored indefinitely until explicitly deleted.
|
||||||
|
func (h *HostFunctions) CacheSet(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
|
||||||
|
if h.cacheClient == nil {
|
||||||
|
return &HostFunctionError{Function: "cache_set", Cause: ErrCacheUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
dm, err := h.cacheClient.NewDMap(cacheDMapName)
|
||||||
|
if err != nil {
|
||||||
|
return &HostFunctionError{Function: "cache_set", Cause: fmt.Errorf("failed to get DMap: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Olric DMap.Put doesn't support TTL in the basic API
|
||||||
|
// For TTL support, consider using Olric's Expire API separately
|
||||||
|
if err := dm.Put(ctx, key, value); err != nil {
|
||||||
|
return &HostFunctionError{Function: "cache_set", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheDelete removes a value from the cache.
|
||||||
|
func (h *HostFunctions) CacheDelete(ctx context.Context, key string) error {
|
||||||
|
if h.cacheClient == nil {
|
||||||
|
return &HostFunctionError{Function: "cache_delete", Cause: ErrCacheUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
dm, err := h.cacheClient.NewDMap(cacheDMapName)
|
||||||
|
if err != nil {
|
||||||
|
return &HostFunctionError{Function: "cache_delete", Cause: fmt.Errorf("failed to get DMap: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := dm.Delete(ctx, key); err != nil {
|
||||||
|
return &HostFunctionError{Function: "cache_delete", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Storage Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// StoragePut uploads data to IPFS and returns the CID.
|
||||||
|
func (h *HostFunctions) StoragePut(ctx context.Context, data []byte) (string, error) {
|
||||||
|
if h.storage == nil {
|
||||||
|
return "", &HostFunctionError{Function: "storage_put", Cause: ErrStorageUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
resp, err := h.storage.Add(ctx, reader, "function-data")
|
||||||
|
if err != nil {
|
||||||
|
return "", &HostFunctionError{Function: "storage_put", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Cid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageGet retrieves data from IPFS by CID.
|
||||||
|
func (h *HostFunctions) StorageGet(ctx context.Context, cid string) ([]byte, error) {
|
||||||
|
if h.storage == nil {
|
||||||
|
return nil, &HostFunctionError{Function: "storage_get", Cause: ErrStorageUnavailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := h.storage.Get(ctx, cid, h.ipfsAPIURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "storage_get", Cause: err}
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "storage_get", Cause: fmt.Errorf("failed to read data: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// PubSub Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// PubSubPublish publishes a message to a topic.
|
||||||
|
func (h *HostFunctions) PubSubPublish(ctx context.Context, topic string, data []byte) error {
|
||||||
|
if h.pubsub == nil {
|
||||||
|
return &HostFunctionError{Function: "pubsub_publish", Cause: fmt.Errorf("pubsub not available")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pubsub adapter handles namespacing internally
|
||||||
|
if err := h.pubsub.Publish(ctx, topic, data); err != nil {
|
||||||
|
return &HostFunctionError{Function: "pubsub_publish", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// WebSocket Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// WSSend sends data to a specific WebSocket client.
|
||||||
|
func (h *HostFunctions) WSSend(ctx context.Context, clientID string, data []byte) error {
|
||||||
|
if h.wsManager == nil {
|
||||||
|
return &HostFunctionError{Function: "ws_send", Cause: ErrWSNotAvailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no clientID provided, use the current invocation's client
|
||||||
|
if clientID == "" {
|
||||||
|
h.invCtxLock.RLock()
|
||||||
|
if h.invCtx != nil && h.invCtx.WSClientID != "" {
|
||||||
|
clientID = h.invCtx.WSClientID
|
||||||
|
}
|
||||||
|
h.invCtxLock.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientID == "" {
|
||||||
|
return &HostFunctionError{Function: "ws_send", Cause: ErrWSNotAvailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.wsManager.Send(clientID, data); err != nil {
|
||||||
|
return &HostFunctionError{Function: "ws_send", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSBroadcast sends data to all WebSocket clients subscribed to a topic.
|
||||||
|
func (h *HostFunctions) WSBroadcast(ctx context.Context, topic string, data []byte) error {
|
||||||
|
if h.wsManager == nil {
|
||||||
|
return &HostFunctionError{Function: "ws_broadcast", Cause: ErrWSNotAvailable}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.wsManager.Broadcast(topic, data); err != nil {
|
||||||
|
return &HostFunctionError{Function: "ws_broadcast", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// HTTP Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// HTTPFetch makes an outbound HTTP request.
|
||||||
|
func (h *HostFunctions) HTTPFetch(ctx context.Context, method, url string, headers map[string]string, body []byte) ([]byte, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if len(body) > 0 {
|
||||||
|
bodyReader = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "http_fetch", Cause: fmt.Errorf("failed to create request: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range headers {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "http_fetch", Cause: err}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "http_fetch", Cause: fmt.Errorf("failed to read response: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode response with status code
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"headers": resp.Header,
|
||||||
|
"body": string(respBody),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &HostFunctionError{Function: "http_fetch", Cause: fmt.Errorf("failed to marshal response: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Context Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable for the function.
|
||||||
|
func (h *HostFunctions) GetEnv(ctx context.Context, key string) (string, error) {
|
||||||
|
h.invCtxLock.RLock()
|
||||||
|
defer h.invCtxLock.RUnlock()
|
||||||
|
|
||||||
|
if h.invCtx == nil || h.invCtx.EnvVars == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.invCtx.EnvVars[key], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecret retrieves a decrypted secret.
|
||||||
|
func (h *HostFunctions) GetSecret(ctx context.Context, name string) (string, error) {
|
||||||
|
if h.secrets == nil {
|
||||||
|
return "", &HostFunctionError{Function: "get_secret", Cause: fmt.Errorf("secrets manager not available")}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.invCtxLock.RLock()
|
||||||
|
namespace := ""
|
||||||
|
if h.invCtx != nil {
|
||||||
|
namespace = h.invCtx.Namespace
|
||||||
|
}
|
||||||
|
h.invCtxLock.RUnlock()
|
||||||
|
|
||||||
|
value, err := h.secrets.Get(ctx, namespace, name)
|
||||||
|
if err != nil {
|
||||||
|
return "", &HostFunctionError{Function: "get_secret", Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestID returns the current request ID.
|
||||||
|
func (h *HostFunctions) GetRequestID(ctx context.Context) string {
|
||||||
|
h.invCtxLock.RLock()
|
||||||
|
defer h.invCtxLock.RUnlock()
|
||||||
|
|
||||||
|
if h.invCtx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.invCtx.RequestID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCallerWallet returns the wallet address of the caller.
|
||||||
|
func (h *HostFunctions) GetCallerWallet(ctx context.Context) string {
|
||||||
|
h.invCtxLock.RLock()
|
||||||
|
defer h.invCtxLock.RUnlock()
|
||||||
|
|
||||||
|
if h.invCtx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.invCtx.CallerWallet
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Job Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// EnqueueBackground queues a function for background execution.
|
||||||
|
func (h *HostFunctions) EnqueueBackground(ctx context.Context, functionName string, payload []byte) (string, error) {
|
||||||
|
// This will be implemented when JobManager is integrated
|
||||||
|
// For now, return an error indicating it's not yet available
|
||||||
|
return "", &HostFunctionError{Function: "enqueue_background", Cause: fmt.Errorf("background jobs not yet implemented")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleOnce schedules a function to run once at a specific time.
|
||||||
|
func (h *HostFunctions) ScheduleOnce(ctx context.Context, functionName string, runAt time.Time, payload []byte) (string, error) {
|
||||||
|
// This will be implemented when Scheduler is integrated
|
||||||
|
return "", &HostFunctionError{Function: "schedule_once", Cause: fmt.Errorf("timers not yet implemented")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Logging Operations
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// LogInfo logs an info message.
|
||||||
|
func (h *HostFunctions) LogInfo(ctx context.Context, message string) {
|
||||||
|
h.logsLock.Lock()
|
||||||
|
defer h.logsLock.Unlock()
|
||||||
|
|
||||||
|
h.logs = append(h.logs, LogEntry{
|
||||||
|
Level: "info",
|
||||||
|
Message: message,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
h.logger.Info(message,
|
||||||
|
zap.String("request_id", h.GetRequestID(ctx)),
|
||||||
|
zap.String("level", "function"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogError logs an error message.
|
||||||
|
func (h *HostFunctions) LogError(ctx context.Context, message string) {
|
||||||
|
h.logsLock.Lock()
|
||||||
|
defer h.logsLock.Unlock()
|
||||||
|
|
||||||
|
h.logs = append(h.logs, LogEntry{
|
||||||
|
Level: "error",
|
||||||
|
Message: message,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
h.logger.Error(message,
|
||||||
|
zap.String("request_id", h.GetRequestID(ctx)),
|
||||||
|
zap.String("level", "function"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Secrets Manager Implementation (built-in)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DBSecretsManager implements SecretsManager using the database.
|
||||||
|
type DBSecretsManager struct {
|
||||||
|
db rqlite.Client
|
||||||
|
encryptionKey []byte // 32-byte AES-256 key
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure DBSecretsManager implements SecretsManager.
|
||||||
|
var _ SecretsManager = (*DBSecretsManager)(nil)
|
||||||
|
|
||||||
|
// NewDBSecretsManager creates a secrets manager backed by the database.
|
||||||
|
func NewDBSecretsManager(db rqlite.Client, encryptionKeyHex string, logger *zap.Logger) (*DBSecretsManager, error) {
|
||||||
|
var key []byte
|
||||||
|
if encryptionKeyHex != "" {
|
||||||
|
var err error
|
||||||
|
key, err = hex.DecodeString(encryptionKeyHex)
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("invalid encryption key: must be 32 bytes hex-encoded")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Generate a random key if none provided
|
||||||
|
key = make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
|
||||||
|
}
|
||||||
|
logger.Warn("Generated random secrets encryption key - secrets will not persist across restarts")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DBSecretsManager{
|
||||||
|
db: db,
|
||||||
|
encryptionKey: key,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores an encrypted secret.
|
||||||
|
func (s *DBSecretsManager) Set(ctx context.Context, namespace, name, value string) error {
|
||||||
|
encrypted, err := s.encrypt([]byte(value))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the secret
|
||||||
|
query := `
|
||||||
|
INSERT INTO function_secrets (id, namespace, name, encrypted_value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(namespace, name) DO UPDATE SET
|
||||||
|
encrypted_value = excluded.encrypted_value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
id := fmt.Sprintf("%s:%s", namespace, name)
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := s.db.Exec(ctx, query, id, namespace, name, encrypted, now, now); err != nil {
|
||||||
|
return fmt.Errorf("failed to save secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a decrypted secret.
|
||||||
|
func (s *DBSecretsManager) Get(ctx context.Context, namespace, name string) (string, error) {
|
||||||
|
query := `SELECT encrypted_value FROM function_secrets WHERE namespace = ? AND name = ?`
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
EncryptedValue []byte `db:"encrypted_value"`
|
||||||
|
}
|
||||||
|
if err := s.db.Query(ctx, &rows, query, namespace, name); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to query secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return "", ErrSecretNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := s.decrypt(rows[0].EncryptedValue)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decrypt secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(decrypted), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all secret names for a namespace.
|
||||||
|
func (s *DBSecretsManager) List(ctx context.Context, namespace string) ([]string, error) {
|
||||||
|
query := `SELECT name FROM function_secrets WHERE namespace = ? ORDER BY name`
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
Name string `db:"name"`
|
||||||
|
}
|
||||||
|
if err := s.db.Query(ctx, &rows, query, namespace); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list secrets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
names[i] = row.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a secret.
|
||||||
|
func (s *DBSecretsManager) Delete(ctx context.Context, namespace, name string) error {
|
||||||
|
query := `DELETE FROM function_secrets WHERE namespace = ? AND name = ?`
|
||||||
|
|
||||||
|
result, err := s.db.Exec(ctx, query, namespace, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrSecretNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt encrypts data using AES-256-GCM.
|
||||||
|
func (s *DBSecretsManager) encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt decrypts data using AES-256-GCM.
|
||||||
|
func (s *DBSecretsManager) decrypt(ciphertext []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
}
|
||||||
|
|
||||||
437
pkg/serverless/invoke.go
Normal file
437
pkg/serverless/invoke.go
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invoker handles function invocation with retry logic and DLQ support.
|
||||||
|
// It wraps the Engine to provide higher-level invocation semantics.
|
||||||
|
type Invoker struct {
|
||||||
|
engine *Engine
|
||||||
|
registry FunctionRegistry
|
||||||
|
hostServices HostServices
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInvoker creates a new function invoker.
|
||||||
|
func NewInvoker(engine *Engine, registry FunctionRegistry, hostServices HostServices, logger *zap.Logger) *Invoker {
|
||||||
|
return &Invoker{
|
||||||
|
engine: engine,
|
||||||
|
registry: registry,
|
||||||
|
hostServices: hostServices,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeRequest contains the parameters for invoking a function.
|
||||||
|
type InvokeRequest struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
FunctionName string `json:"function_name"`
|
||||||
|
Version int `json:"version,omitempty"` // 0 = latest
|
||||||
|
Input []byte `json:"input"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
CallerWallet string `json:"caller_wallet,omitempty"`
|
||||||
|
WSClientID string `json:"ws_client_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeResponse contains the result of a function invocation.
|
||||||
|
type InvokeResponse struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Output []byte `json:"output,omitempty"`
|
||||||
|
Status InvocationStatus `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMS int64 `json:"duration_ms"`
|
||||||
|
Retries int `json:"retries,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke executes a function with automatic retry logic.
|
||||||
|
func (i *Invoker) Invoke(ctx context.Context, req *InvokeRequest) (*InvokeResponse, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, &ValidationError{Field: "request", Message: "cannot be nil"}
|
||||||
|
}
|
||||||
|
if req.FunctionName == "" {
|
||||||
|
return nil, &ValidationError{Field: "function_name", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
if req.Namespace == "" {
|
||||||
|
return nil, &ValidationError{Field: "namespace", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestID := uuid.New().String()
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Get function from registry
|
||||||
|
fn, err := i.registry.Get(ctx, req.Namespace, req.FunctionName, req.Version)
|
||||||
|
if err != nil {
|
||||||
|
return &InvokeResponse{
|
||||||
|
RequestID: requestID,
|
||||||
|
Status: InvocationStatusError,
|
||||||
|
Error: err.Error(),
|
||||||
|
DurationMS: time.Since(startTime).Milliseconds(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get environment variables
|
||||||
|
envVars, err := i.getEnvVars(ctx, fn.ID)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Warn("Failed to get env vars", zap.Error(err))
|
||||||
|
envVars = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build invocation context
|
||||||
|
invCtx := &InvocationContext{
|
||||||
|
RequestID: requestID,
|
||||||
|
FunctionID: fn.ID,
|
||||||
|
FunctionName: fn.Name,
|
||||||
|
Namespace: fn.Namespace,
|
||||||
|
CallerWallet: req.CallerWallet,
|
||||||
|
TriggerType: req.TriggerType,
|
||||||
|
WSClientID: req.WSClientID,
|
||||||
|
EnvVars: envVars,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute with retry logic
|
||||||
|
output, retries, err := i.executeWithRetry(ctx, fn, req.Input, invCtx)
|
||||||
|
|
||||||
|
response := &InvokeResponse{
|
||||||
|
RequestID: requestID,
|
||||||
|
Output: output,
|
||||||
|
DurationMS: time.Since(startTime).Milliseconds(),
|
||||||
|
Retries: retries,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
response.Status = InvocationStatusError
|
||||||
|
response.Error = err.Error()
|
||||||
|
|
||||||
|
// Check if it's a timeout
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
response.Status = InvocationStatusTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Status = InvocationStatusSuccess
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeByID invokes a function by its ID.
|
||||||
|
func (i *Invoker) InvokeByID(ctx context.Context, functionID string, input []byte, invCtx *InvocationContext) (*InvokeResponse, error) {
|
||||||
|
// Get function from registry by ID
|
||||||
|
fn, err := i.getByID(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if invCtx == nil {
|
||||||
|
invCtx = &InvocationContext{
|
||||||
|
RequestID: uuid.New().String(),
|
||||||
|
FunctionID: fn.ID,
|
||||||
|
FunctionName: fn.Name,
|
||||||
|
Namespace: fn.Namespace,
|
||||||
|
TriggerType: TriggerTypeHTTP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
output, retries, err := i.executeWithRetry(ctx, fn, input, invCtx)
|
||||||
|
|
||||||
|
response := &InvokeResponse{
|
||||||
|
RequestID: invCtx.RequestID,
|
||||||
|
Output: output,
|
||||||
|
DurationMS: time.Since(startTime).Milliseconds(),
|
||||||
|
Retries: retries,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
response.Status = InvocationStatusError
|
||||||
|
response.Error = err.Error()
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Status = InvocationStatusSuccess
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeWithRetry executes a function with retry logic and DLQ.
|
||||||
|
func (i *Invoker) executeWithRetry(ctx context.Context, fn *Function, input []byte, invCtx *InvocationContext) ([]byte, int, error) {
|
||||||
|
var lastErr error
|
||||||
|
var output []byte
|
||||||
|
|
||||||
|
maxAttempts := fn.RetryCount + 1 // Initial attempt + retries
|
||||||
|
if maxAttempts < 1 {
|
||||||
|
maxAttempts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
|
// Check if context is cancelled
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, attempt, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the function
|
||||||
|
output, lastErr = i.engine.Execute(ctx, fn, input, invCtx)
|
||||||
|
if lastErr == nil {
|
||||||
|
return output, attempt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i.logger.Warn("Function execution failed",
|
||||||
|
zap.String("function", fn.Name),
|
||||||
|
zap.String("request_id", invCtx.RequestID),
|
||||||
|
zap.Int("attempt", attempt+1),
|
||||||
|
zap.Int("max_attempts", maxAttempts),
|
||||||
|
zap.Error(lastErr),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Don't retry on certain errors
|
||||||
|
if !i.isRetryable(lastErr) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't wait after the last attempt
|
||||||
|
if attempt < maxAttempts-1 {
|
||||||
|
delay := i.calculateBackoff(fn.RetryDelaySeconds, attempt)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, attempt + 1, ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
// Continue to next attempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted - send to DLQ if configured
|
||||||
|
if fn.DLQTopic != "" {
|
||||||
|
i.sendToDLQ(ctx, fn, input, invCtx, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, maxAttempts - 1, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRetryable determines if an error should trigger a retry.
|
||||||
|
func (i *Invoker) isRetryable(err error) bool {
|
||||||
|
// Don't retry validation errors or not-found errors
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry resource exhaustion (rate limits, memory)
|
||||||
|
if IsResourceExhausted(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry service unavailable errors
|
||||||
|
if IsServiceUnavailable(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry execution errors (could be transient)
|
||||||
|
var execErr *ExecutionError
|
||||||
|
if ok := errorAs(err, &execErr); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to retryable for unknown errors
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateBackoff calculates the delay before the next retry attempt.
|
||||||
|
// Uses exponential backoff with jitter.
|
||||||
|
func (i *Invoker) calculateBackoff(baseDelaySeconds, attempt int) time.Duration {
|
||||||
|
if baseDelaySeconds <= 0 {
|
||||||
|
baseDelaySeconds = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: delay * 2^attempt
|
||||||
|
delay := time.Duration(baseDelaySeconds) * time.Second
|
||||||
|
for j := 0; j < attempt; j++ {
|
||||||
|
delay *= 2
|
||||||
|
if delay > 5*time.Minute {
|
||||||
|
delay = 5 * time.Minute
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendToDLQ sends a failed invocation to the dead letter queue.
|
||||||
|
func (i *Invoker) sendToDLQ(ctx context.Context, fn *Function, input []byte, invCtx *InvocationContext, err error) {
|
||||||
|
dlqMessage := DLQMessage{
|
||||||
|
FunctionID: fn.ID,
|
||||||
|
FunctionName: fn.Name,
|
||||||
|
Namespace: fn.Namespace,
|
||||||
|
RequestID: invCtx.RequestID,
|
||||||
|
Input: input,
|
||||||
|
Error: err.Error(),
|
||||||
|
FailedAt: time.Now(),
|
||||||
|
TriggerType: invCtx.TriggerType,
|
||||||
|
CallerWallet: invCtx.CallerWallet,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, marshalErr := json.Marshal(dlqMessage)
|
||||||
|
if marshalErr != nil {
|
||||||
|
i.logger.Error("Failed to marshal DLQ message",
|
||||||
|
zap.Error(marshalErr),
|
||||||
|
zap.String("function", fn.Name),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to DLQ topic via host services
|
||||||
|
if err := i.hostServices.PubSubPublish(ctx, fn.DLQTopic, data); err != nil {
|
||||||
|
i.logger.Error("Failed to send to DLQ",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("function", fn.Name),
|
||||||
|
zap.String("dlq_topic", fn.DLQTopic),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
i.logger.Info("Sent failed invocation to DLQ",
|
||||||
|
zap.String("function", fn.Name),
|
||||||
|
zap.String("dlq_topic", fn.DLQTopic),
|
||||||
|
zap.String("request_id", invCtx.RequestID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvVars retrieves environment variables for a function.
|
||||||
|
func (i *Invoker) getEnvVars(ctx context.Context, functionID string) (map[string]string, error) {
|
||||||
|
// Type assert to get extended registry methods
|
||||||
|
if reg, ok := i.registry.(*Registry); ok {
|
||||||
|
return reg.GetEnvVars(ctx, functionID)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getByID retrieves a function by ID.
|
||||||
|
func (i *Invoker) getByID(ctx context.Context, functionID string) (*Function, error) {
|
||||||
|
// Type assert to get extended registry methods
|
||||||
|
if reg, ok := i.registry.(*Registry); ok {
|
||||||
|
return reg.GetByID(ctx, functionID)
|
||||||
|
}
|
||||||
|
return nil, ErrFunctionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// DLQMessage represents a message sent to the dead letter queue.
|
||||||
|
type DLQMessage struct {
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
FunctionName string `json:"function_name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Input []byte `json:"input"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
FailedAt time.Time `json:"failed_at"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
CallerWallet string `json:"caller_wallet,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorAs is a helper to avoid import of errors package.
|
||||||
|
func errorAs(err error, target interface{}) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Simple type assertion for our custom error types
|
||||||
|
switch t := target.(type) {
|
||||||
|
case **ExecutionError:
|
||||||
|
if e, ok := err.(*ExecutionError); ok {
|
||||||
|
*t = e
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Batch Invocation (for future use)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// BatchInvokeRequest contains parameters for batch invocation.
|
||||||
|
type BatchInvokeRequest struct {
|
||||||
|
Requests []*InvokeRequest `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchInvokeResponse contains results of batch invocation.
|
||||||
|
type BatchInvokeResponse struct {
|
||||||
|
Responses []*InvokeResponse `json:"responses"`
|
||||||
|
Duration time.Duration `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchInvoke executes multiple functions in parallel.
|
||||||
|
func (i *Invoker) BatchInvoke(ctx context.Context, req *BatchInvokeRequest) (*BatchInvokeResponse, error) {
|
||||||
|
if req == nil || len(req.Requests) == 0 {
|
||||||
|
return nil, &ValidationError{Field: "requests", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
responses := make([]*InvokeResponse, len(req.Requests))
|
||||||
|
|
||||||
|
// For simplicity, execute sequentially for now
|
||||||
|
// TODO: Implement parallel execution with goroutines and semaphore
|
||||||
|
for idx, invReq := range req.Requests {
|
||||||
|
resp, err := i.Invoke(ctx, invReq)
|
||||||
|
if err != nil && resp == nil {
|
||||||
|
responses[idx] = &InvokeResponse{
|
||||||
|
RequestID: uuid.New().String(),
|
||||||
|
Status: InvocationStatusError,
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
responses[idx] = resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &BatchInvokeResponse{
|
||||||
|
Responses: responses,
|
||||||
|
Duration: time.Since(startTime),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Public Invocation Helpers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CanInvoke checks if a caller is authorized to invoke a function.
|
||||||
|
func (i *Invoker) CanInvoke(ctx context.Context, namespace, functionName string, callerWallet string) (bool, error) {
|
||||||
|
fn, err := i.registry.Get(ctx, namespace, functionName, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public functions can be invoked by anyone
|
||||||
|
if fn.IsPublic {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-public functions require the caller to be in the same namespace
|
||||||
|
// (simplified authorization - can be extended)
|
||||||
|
if callerWallet == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, just check if caller wallet matches namespace
|
||||||
|
// In production, you'd check group membership, roles, etc.
|
||||||
|
return callerWallet == namespace || fn.CreatedBy == callerWallet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFunctionInfo returns basic info about a function for invocation.
|
||||||
|
func (i *Invoker) GetFunctionInfo(ctx context.Context, namespace, functionName string, version int) (*Function, error) {
|
||||||
|
return i.registry.Get(ctx, namespace, functionName, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateInput performs basic input validation.
|
||||||
|
func (i *Invoker) ValidateInput(input []byte, maxSize int) error {
|
||||||
|
if maxSize > 0 && len(input) > maxSize {
|
||||||
|
return &ValidationError{
|
||||||
|
Field: "input",
|
||||||
|
Message: fmt.Sprintf("exceeds maximum size of %d bytes", maxSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
431
pkg/serverless/registry.go
Normal file
431
pkg/serverless/registry.go
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/ipfs"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure Registry implements FunctionRegistry interface.
|
||||||
|
var _ FunctionRegistry = (*Registry)(nil)
|
||||||
|
|
||||||
|
// Registry manages function metadata in RQLite and bytecode in IPFS.
|
||||||
|
// It implements the FunctionRegistry interface.
|
||||||
|
type Registry struct {
|
||||||
|
db rqlite.Client
|
||||||
|
ipfs ipfs.IPFSClient
|
||||||
|
ipfsAPIURL string
|
||||||
|
logger *zap.Logger
|
||||||
|
tableName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryConfig holds configuration for the Registry.
|
||||||
|
type RegistryConfig struct {
|
||||||
|
IPFSAPIURL string // IPFS API URL for content retrieval
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry creates a new function registry.
|
||||||
|
func NewRegistry(db rqlite.Client, ipfsClient ipfs.IPFSClient, cfg RegistryConfig, logger *zap.Logger) *Registry {
|
||||||
|
return &Registry{
|
||||||
|
db: db,
|
||||||
|
ipfs: ipfsClient,
|
||||||
|
ipfsAPIURL: cfg.IPFSAPIURL,
|
||||||
|
logger: logger,
|
||||||
|
tableName: "functions",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register deploys a new function or creates a new version.
|
||||||
|
func (r *Registry) Register(ctx context.Context, fn *FunctionDefinition, wasmBytes []byte) error {
|
||||||
|
if fn == nil {
|
||||||
|
return &ValidationError{Field: "definition", Message: "cannot be nil"}
|
||||||
|
}
|
||||||
|
if fn.Name == "" {
|
||||||
|
return &ValidationError{Field: "name", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
if fn.Namespace == "" {
|
||||||
|
return &ValidationError{Field: "namespace", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
if len(wasmBytes) == 0 {
|
||||||
|
return &ValidationError{Field: "wasmBytes", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload WASM to IPFS
|
||||||
|
wasmCID, err := r.uploadWASM(ctx, wasmBytes, fn.Name)
|
||||||
|
if err != nil {
|
||||||
|
return &DeployError{FunctionName: fn.Name, Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine version (auto-increment if not specified)
|
||||||
|
version := fn.Version
|
||||||
|
if version == 0 {
|
||||||
|
latestVersion, err := r.getLatestVersion(ctx, fn.Namespace, fn.Name)
|
||||||
|
if err != nil && err != ErrFunctionNotFound {
|
||||||
|
return &DeployError{FunctionName: fn.Name, Cause: err}
|
||||||
|
}
|
||||||
|
version = latestVersion + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults
|
||||||
|
memoryLimit := fn.MemoryLimitMB
|
||||||
|
if memoryLimit == 0 {
|
||||||
|
memoryLimit = 64
|
||||||
|
}
|
||||||
|
timeout := fn.TimeoutSeconds
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 30
|
||||||
|
}
|
||||||
|
retryDelay := fn.RetryDelaySeconds
|
||||||
|
if retryDelay == 0 {
|
||||||
|
retryDelay = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ID
|
||||||
|
id := uuid.New().String()
|
||||||
|
|
||||||
|
// Insert function record
|
||||||
|
query := `
|
||||||
|
INSERT INTO functions (
|
||||||
|
id, name, namespace, version, wasm_cid,
|
||||||
|
memory_limit_mb, timeout_seconds, is_public,
|
||||||
|
retry_count, retry_delay_seconds, dlq_topic,
|
||||||
|
status, created_at, updated_at, created_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
now := time.Now()
|
||||||
|
_, err = r.db.Exec(ctx, query,
|
||||||
|
id, fn.Name, fn.Namespace, version, wasmCID,
|
||||||
|
memoryLimit, timeout, fn.IsPublic,
|
||||||
|
fn.RetryCount, retryDelay, fn.DLQTopic,
|
||||||
|
string(FunctionStatusActive), now, now, fn.Namespace, // created_by = namespace for now
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return &DeployError{FunctionName: fn.Name, Cause: fmt.Errorf("failed to insert function: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert environment variables
|
||||||
|
if err := r.saveEnvVars(ctx, id, fn.EnvVars); err != nil {
|
||||||
|
return &DeployError{FunctionName: fn.Name, Cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Function registered",
|
||||||
|
zap.String("id", id),
|
||||||
|
zap.String("name", fn.Name),
|
||||||
|
zap.String("namespace", fn.Namespace),
|
||||||
|
zap.Int("version", version),
|
||||||
|
zap.String("wasm_cid", wasmCID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a function by name and optional version.
|
||||||
|
// If version is 0, returns the latest version.
|
||||||
|
func (r *Registry) Get(ctx context.Context, namespace, name string, version int) (*Function, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if version == 0 {
|
||||||
|
// Get latest version
|
||||||
|
query = `
|
||||||
|
SELECT id, name, namespace, version, wasm_cid, source_cid,
|
||||||
|
memory_limit_mb, timeout_seconds, is_public,
|
||||||
|
retry_count, retry_delay_seconds, dlq_topic,
|
||||||
|
status, created_at, updated_at, created_by
|
||||||
|
FROM functions
|
||||||
|
WHERE namespace = ? AND name = ? AND status = ?
|
||||||
|
ORDER BY version DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
args = []interface{}{namespace, name, string(FunctionStatusActive)}
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT id, name, namespace, version, wasm_cid, source_cid,
|
||||||
|
memory_limit_mb, timeout_seconds, is_public,
|
||||||
|
retry_count, retry_delay_seconds, dlq_topic,
|
||||||
|
status, created_at, updated_at, created_by
|
||||||
|
FROM functions
|
||||||
|
WHERE namespace = ? AND name = ? AND version = ?
|
||||||
|
`
|
||||||
|
args = []interface{}{namespace, name, version}
|
||||||
|
}
|
||||||
|
|
||||||
|
var functions []functionRow
|
||||||
|
if err := r.db.Query(ctx, &functions, query, args...); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query function: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(functions) == 0 {
|
||||||
|
if version == 0 {
|
||||||
|
return nil, ErrFunctionNotFound
|
||||||
|
}
|
||||||
|
return nil, ErrVersionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.rowToFunction(&functions[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all functions for a namespace.
|
||||||
|
func (r *Registry) List(ctx context.Context, namespace string) ([]*Function, error) {
|
||||||
|
// Get latest version of each function in the namespace
|
||||||
|
query := `
|
||||||
|
SELECT f.id, f.name, f.namespace, f.version, f.wasm_cid, f.source_cid,
|
||||||
|
f.memory_limit_mb, f.timeout_seconds, f.is_public,
|
||||||
|
f.retry_count, f.retry_delay_seconds, f.dlq_topic,
|
||||||
|
f.status, f.created_at, f.updated_at, f.created_by
|
||||||
|
FROM functions f
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT namespace, name, MAX(version) as max_version
|
||||||
|
FROM functions
|
||||||
|
WHERE namespace = ? AND status = ?
|
||||||
|
GROUP BY namespace, name
|
||||||
|
) latest ON f.namespace = latest.namespace
|
||||||
|
AND f.name = latest.name
|
||||||
|
AND f.version = latest.max_version
|
||||||
|
ORDER BY f.name
|
||||||
|
`
|
||||||
|
|
||||||
|
var rows []functionRow
|
||||||
|
if err := r.db.Query(ctx, &rows, query, namespace, string(FunctionStatusActive)); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list functions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
functions := make([]*Function, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
functions[i] = r.rowToFunction(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return functions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a function. If version is 0, removes all versions.
|
||||||
|
func (r *Registry) Delete(ctx context.Context, namespace, name string, version int) error {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if version == 0 {
|
||||||
|
// Mark all versions as inactive (soft delete)
|
||||||
|
query = `UPDATE functions SET status = ?, updated_at = ? WHERE namespace = ? AND name = ?`
|
||||||
|
args = []interface{}{string(FunctionStatusInactive), time.Now(), namespace, name}
|
||||||
|
} else {
|
||||||
|
query = `UPDATE functions SET status = ?, updated_at = ? WHERE namespace = ? AND name = ? AND version = ?`
|
||||||
|
args = []interface{}{string(FunctionStatusInactive), time.Now(), namespace, name, version}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.db.Exec(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete function: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
if version == 0 {
|
||||||
|
return ErrFunctionNotFound
|
||||||
|
}
|
||||||
|
return ErrVersionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("Function deleted",
|
||||||
|
zap.String("namespace", namespace),
|
||||||
|
zap.String("name", name),
|
||||||
|
zap.Int("version", version),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWASMBytes retrieves the compiled WASM bytecode for a function.
|
||||||
|
func (r *Registry) GetWASMBytes(ctx context.Context, wasmCID string) ([]byte, error) {
|
||||||
|
if wasmCID == "" {
|
||||||
|
return nil, &ValidationError{Field: "wasmCID", Message: "cannot be empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := r.ipfs.Get(ctx, wasmCID, r.ipfsAPIURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get WASM from IPFS: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read WASM data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnvVars retrieves environment variables for a function.
|
||||||
|
func (r *Registry) GetEnvVars(ctx context.Context, functionID string) (map[string]string, error) {
|
||||||
|
query := `SELECT key, value FROM function_env_vars WHERE function_id = ?`
|
||||||
|
|
||||||
|
var rows []envVarRow
|
||||||
|
if err := r.db.Query(ctx, &rows, query, functionID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query env vars: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envVars := make(map[string]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
envVars[row.Key] = row.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return envVars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a function by its ID.
|
||||||
|
func (r *Registry) GetByID(ctx context.Context, id string) (*Function, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, namespace, version, wasm_cid, source_cid,
|
||||||
|
memory_limit_mb, timeout_seconds, is_public,
|
||||||
|
retry_count, retry_delay_seconds, dlq_topic,
|
||||||
|
status, created_at, updated_at, created_by
|
||||||
|
FROM functions
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var functions []functionRow
|
||||||
|
if err := r.db.Query(ctx, &functions, query, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query function: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(functions) == 0 {
|
||||||
|
return nil, ErrFunctionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.rowToFunction(&functions[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVersions returns all versions of a function.
|
||||||
|
func (r *Registry) ListVersions(ctx context.Context, namespace, name string) ([]*Function, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, namespace, version, wasm_cid, source_cid,
|
||||||
|
memory_limit_mb, timeout_seconds, is_public,
|
||||||
|
retry_count, retry_delay_seconds, dlq_topic,
|
||||||
|
status, created_at, updated_at, created_by
|
||||||
|
FROM functions
|
||||||
|
WHERE namespace = ? AND name = ?
|
||||||
|
ORDER BY version DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
var rows []functionRow
|
||||||
|
if err := r.db.Query(ctx, &rows, query, namespace, name); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list versions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
functions := make([]*Function, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
functions[i] = r.rowToFunction(&row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return functions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Private helpers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// uploadWASM uploads WASM bytecode to IPFS and returns the CID.
|
||||||
|
func (r *Registry) uploadWASM(ctx context.Context, wasmBytes []byte, name string) (string, error) {
|
||||||
|
reader := bytes.NewReader(wasmBytes)
|
||||||
|
resp, err := r.ipfs.Add(ctx, reader, name+".wasm")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to upload WASM to IPFS: %w", err)
|
||||||
|
}
|
||||||
|
return resp.Cid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLatestVersion returns the latest version number for a function.
|
||||||
|
func (r *Registry) getLatestVersion(ctx context.Context, namespace, name string) (int, error) {
|
||||||
|
query := `SELECT MAX(version) FROM functions WHERE namespace = ? AND name = ?`
|
||||||
|
|
||||||
|
var maxVersion sql.NullInt64
|
||||||
|
var results []struct {
|
||||||
|
MaxVersion sql.NullInt64 `db:"max(version)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.Query(ctx, &results, query, namespace, name); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 || !results[0].MaxVersion.Valid {
|
||||||
|
return 0, ErrFunctionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
maxVersion = results[0].MaxVersion
|
||||||
|
return int(maxVersion.Int64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveEnvVars saves environment variables for a function.
|
||||||
|
func (r *Registry) saveEnvVars(ctx context.Context, functionID string, envVars map[string]string) error {
|
||||||
|
if len(envVars) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range envVars {
|
||||||
|
id := uuid.New().String()
|
||||||
|
query := `INSERT INTO function_env_vars (id, function_id, key, value, created_at) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
if _, err := r.db.Exec(ctx, query, id, functionID, key, value, time.Now()); err != nil {
|
||||||
|
return fmt.Errorf("failed to save env var '%s': %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowToFunction converts a database row to a Function struct.
|
||||||
|
func (r *Registry) rowToFunction(row *functionRow) *Function {
|
||||||
|
return &Function{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
Namespace: row.Namespace,
|
||||||
|
Version: row.Version,
|
||||||
|
WASMCID: row.WASMCID,
|
||||||
|
SourceCID: row.SourceCID.String,
|
||||||
|
MemoryLimitMB: row.MemoryLimitMB,
|
||||||
|
TimeoutSeconds: row.TimeoutSeconds,
|
||||||
|
IsPublic: row.IsPublic,
|
||||||
|
RetryCount: row.RetryCount,
|
||||||
|
RetryDelaySeconds: row.RetryDelaySeconds,
|
||||||
|
DLQTopic: row.DLQTopic.String,
|
||||||
|
Status: FunctionStatus(row.Status),
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
CreatedBy: row.CreatedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Database row types (internal)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type functionRow struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Namespace string `db:"namespace"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
WASMCID string `db:"wasm_cid"`
|
||||||
|
SourceCID sql.NullString `db:"source_cid"`
|
||||||
|
MemoryLimitMB int `db:"memory_limit_mb"`
|
||||||
|
TimeoutSeconds int `db:"timeout_seconds"`
|
||||||
|
IsPublic bool `db:"is_public"`
|
||||||
|
RetryCount int `db:"retry_count"`
|
||||||
|
RetryDelaySeconds int `db:"retry_delay_seconds"`
|
||||||
|
DLQTopic sql.NullString `db:"dlq_topic"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
CreatedBy string `db:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type envVarRow struct {
|
||||||
|
Key string `db:"key"`
|
||||||
|
Value string `db:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
373
pkg/serverless/types.go
Normal file
373
pkg/serverless/types.go
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
// Package serverless provides a WASM-based serverless function engine for the Orama Network.
|
||||||
|
// It enables users to deploy and execute Go functions (compiled to WASM) across all nodes,
|
||||||
|
// with support for HTTP/WebSocket triggers, cron jobs, database triggers, pub/sub triggers,
|
||||||
|
// one-time timers, retries with DLQ, and background jobs.
|
||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FunctionStatus represents the current state of a deployed function.
|
||||||
|
type FunctionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FunctionStatusActive FunctionStatus = "active"
|
||||||
|
FunctionStatusInactive FunctionStatus = "inactive"
|
||||||
|
FunctionStatusError FunctionStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriggerType identifies the type of event that triggered a function invocation.
|
||||||
|
type TriggerType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TriggerTypeHTTP TriggerType = "http"
|
||||||
|
TriggerTypeWebSocket TriggerType = "websocket"
|
||||||
|
TriggerTypeCron TriggerType = "cron"
|
||||||
|
TriggerTypeDatabase TriggerType = "database"
|
||||||
|
TriggerTypePubSub TriggerType = "pubsub"
|
||||||
|
TriggerTypeTimer TriggerType = "timer"
|
||||||
|
TriggerTypeJob TriggerType = "job"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobStatus represents the current state of a background job.
|
||||||
|
type JobStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
JobStatusPending JobStatus = "pending"
|
||||||
|
JobStatusRunning JobStatus = "running"
|
||||||
|
JobStatusCompleted JobStatus = "completed"
|
||||||
|
JobStatusFailed JobStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InvocationStatus represents the result of a function invocation.
|
||||||
|
type InvocationStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InvocationStatusSuccess InvocationStatus = "success"
|
||||||
|
InvocationStatusError InvocationStatus = "error"
|
||||||
|
InvocationStatusTimeout InvocationStatus = "timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBOperation represents the type of database operation that triggered a function.
|
||||||
|
type DBOperation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DBOperationInsert DBOperation = "INSERT"
|
||||||
|
DBOperationUpdate DBOperation = "UPDATE"
|
||||||
|
DBOperationDelete DBOperation = "DELETE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Core Interfaces (following Interface Segregation Principle)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// FunctionRegistry manages function metadata and bytecode storage.
|
||||||
|
// Responsible for CRUD operations on function definitions.
|
||||||
|
type FunctionRegistry interface {
|
||||||
|
// Register deploys a new function or updates an existing one.
|
||||||
|
Register(ctx context.Context, fn *FunctionDefinition, wasmBytes []byte) error
|
||||||
|
|
||||||
|
// Get retrieves a function by name and optional version.
|
||||||
|
// If version is 0, returns the latest version.
|
||||||
|
Get(ctx context.Context, namespace, name string, version int) (*Function, error)
|
||||||
|
|
||||||
|
// List returns all functions for a namespace.
|
||||||
|
List(ctx context.Context, namespace string) ([]*Function, error)
|
||||||
|
|
||||||
|
// Delete removes a function. If version is 0, removes all versions.
|
||||||
|
Delete(ctx context.Context, namespace, name string, version int) error
|
||||||
|
|
||||||
|
// GetWASMBytes retrieves the compiled WASM bytecode for a function.
|
||||||
|
GetWASMBytes(ctx context.Context, wasmCID string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionExecutor handles the actual execution of WASM functions.
|
||||||
|
type FunctionExecutor interface {
|
||||||
|
// Execute runs a function with the given input and returns the output.
|
||||||
|
Execute(ctx context.Context, fn *Function, input []byte, invCtx *InvocationContext) ([]byte, error)
|
||||||
|
|
||||||
|
// Precompile compiles a WASM module and caches it for faster execution.
|
||||||
|
Precompile(ctx context.Context, wasmCID string, wasmBytes []byte) error
|
||||||
|
|
||||||
|
// Invalidate removes a compiled module from the cache.
|
||||||
|
Invalidate(wasmCID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretsManager handles secure storage and retrieval of secrets.
|
||||||
|
type SecretsManager interface {
|
||||||
|
// Set stores an encrypted secret.
|
||||||
|
Set(ctx context.Context, namespace, name, value string) error
|
||||||
|
|
||||||
|
// Get retrieves a decrypted secret.
|
||||||
|
Get(ctx context.Context, namespace, name string) (string, error)
|
||||||
|
|
||||||
|
// List returns all secret names for a namespace (not values).
|
||||||
|
List(ctx context.Context, namespace string) ([]string, error)
|
||||||
|
|
||||||
|
// Delete removes a secret.
|
||||||
|
Delete(ctx context.Context, namespace, name string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerManager manages function triggers (cron, database, pubsub, timer).
|
||||||
|
type TriggerManager interface {
|
||||||
|
// AddCronTrigger adds a cron-based trigger to a function.
|
||||||
|
AddCronTrigger(ctx context.Context, functionID, cronExpr string) error
|
||||||
|
|
||||||
|
// AddDBTrigger adds a database trigger to a function.
|
||||||
|
AddDBTrigger(ctx context.Context, functionID, tableName string, operation DBOperation, condition string) error
|
||||||
|
|
||||||
|
// AddPubSubTrigger adds a pubsub trigger to a function.
|
||||||
|
AddPubSubTrigger(ctx context.Context, functionID, topic string) error
|
||||||
|
|
||||||
|
// ScheduleOnce schedules a one-time execution.
|
||||||
|
ScheduleOnce(ctx context.Context, functionID string, runAt time.Time, payload []byte) (string, error)
|
||||||
|
|
||||||
|
// RemoveTrigger removes a trigger by ID.
|
||||||
|
RemoveTrigger(ctx context.Context, triggerID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobManager manages background job execution.
|
||||||
|
type JobManager interface {
|
||||||
|
// Enqueue adds a job to the queue for background execution.
|
||||||
|
Enqueue(ctx context.Context, functionID string, payload []byte) (string, error)
|
||||||
|
|
||||||
|
// GetStatus retrieves the current status of a job.
|
||||||
|
GetStatus(ctx context.Context, jobID string) (*Job, error)
|
||||||
|
|
||||||
|
// List returns jobs for a function.
|
||||||
|
List(ctx context.Context, functionID string, limit int) ([]*Job, error)
|
||||||
|
|
||||||
|
// Cancel attempts to cancel a pending or running job.
|
||||||
|
Cancel(ctx context.Context, jobID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketManager manages WebSocket connections for function streaming.
|
||||||
|
type WebSocketManager interface {
|
||||||
|
// Register registers a new WebSocket connection.
|
||||||
|
Register(clientID string, conn WebSocketConn)
|
||||||
|
|
||||||
|
// Unregister removes a WebSocket connection.
|
||||||
|
Unregister(clientID string)
|
||||||
|
|
||||||
|
// Send sends data to a specific client.
|
||||||
|
Send(clientID string, data []byte) error
|
||||||
|
|
||||||
|
// Broadcast sends data to all clients subscribed to a topic.
|
||||||
|
Broadcast(topic string, data []byte) error
|
||||||
|
|
||||||
|
// Subscribe adds a client to a topic.
|
||||||
|
Subscribe(clientID, topic string)
|
||||||
|
|
||||||
|
// Unsubscribe removes a client from a topic.
|
||||||
|
Unsubscribe(clientID, topic string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketConn abstracts a WebSocket connection for testability.
|
||||||
|
type WebSocketConn interface {
|
||||||
|
WriteMessage(messageType int, data []byte) error
|
||||||
|
ReadMessage() (messageType int, p []byte, err error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Data Types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// FunctionDefinition contains the configuration for deploying a function.
|
||||||
|
type FunctionDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Version int `json:"version,omitempty"`
|
||||||
|
MemoryLimitMB int `json:"memory_limit_mb,omitempty"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||||
|
IsPublic bool `json:"is_public,omitempty"`
|
||||||
|
RetryCount int `json:"retry_count,omitempty"`
|
||||||
|
RetryDelaySeconds int `json:"retry_delay_seconds,omitempty"`
|
||||||
|
DLQTopic string `json:"dlq_topic,omitempty"`
|
||||||
|
EnvVars map[string]string `json:"env_vars,omitempty"`
|
||||||
|
CronExpressions []string `json:"cron_expressions,omitempty"`
|
||||||
|
DBTriggers []DBTriggerConfig `json:"db_triggers,omitempty"`
|
||||||
|
PubSubTopics []string `json:"pubsub_topics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBTriggerConfig defines a database trigger configuration.
|
||||||
|
type DBTriggerConfig struct {
|
||||||
|
Table string `json:"table"`
|
||||||
|
Operation DBOperation `json:"operation"`
|
||||||
|
Condition string `json:"condition,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function represents a deployed serverless function.
|
||||||
|
type Function struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
WASMCID string `json:"wasm_cid"`
|
||||||
|
SourceCID string `json:"source_cid,omitempty"`
|
||||||
|
MemoryLimitMB int `json:"memory_limit_mb"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
RetryCount int `json:"retry_count"`
|
||||||
|
RetryDelaySeconds int `json:"retry_delay_seconds"`
|
||||||
|
DLQTopic string `json:"dlq_topic,omitempty"`
|
||||||
|
Status FunctionStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvocationContext provides context for a function invocation.
|
||||||
|
type InvocationContext struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
FunctionName string `json:"function_name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
CallerWallet string `json:"caller_wallet,omitempty"`
|
||||||
|
TriggerType TriggerType `json:"trigger_type"`
|
||||||
|
WSClientID string `json:"ws_client_id,omitempty"`
|
||||||
|
EnvVars map[string]string `json:"env_vars,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvocationResult represents the result of a function invocation.
|
||||||
|
type InvocationResult struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Output []byte `json:"output,omitempty"`
|
||||||
|
Status InvocationStatus `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMS int64 `json:"duration_ms"`
|
||||||
|
Logs []LogEntry `json:"logs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEntry represents a log message from a function.
|
||||||
|
type LogEntry struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job represents a background job.
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
Payload []byte `json:"payload,omitempty"`
|
||||||
|
Status JobStatus `json:"status"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Result []byte `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CronTrigger represents a cron-based trigger.
|
||||||
|
type CronTrigger struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
CronExpression string `json:"cron_expression"`
|
||||||
|
NextRunAt *time.Time `json:"next_run_at,omitempty"`
|
||||||
|
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBTrigger represents a database trigger.
|
||||||
|
type DBTrigger struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
TableName string `json:"table_name"`
|
||||||
|
Operation DBOperation `json:"operation"`
|
||||||
|
Condition string `json:"condition,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PubSubTrigger represents a pubsub trigger.
|
||||||
|
type PubSubTrigger struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer represents a one-time scheduled execution.
|
||||||
|
type Timer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
RunAt time.Time `json:"run_at"`
|
||||||
|
Payload []byte `json:"payload,omitempty"`
|
||||||
|
Status JobStatus `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBChangeEvent is passed to functions triggered by database changes.
|
||||||
|
type DBChangeEvent struct {
|
||||||
|
Table string `json:"table"`
|
||||||
|
Operation DBOperation `json:"operation"`
|
||||||
|
Row map[string]interface{} `json:"row"`
|
||||||
|
OldRow map[string]interface{} `json:"old_row,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Host Function Types (passed to WASM functions)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// HostServices provides access to Orama services from within WASM functions.
|
||||||
|
// This interface is implemented by the host and exposed to WASM modules.
|
||||||
|
type HostServices interface {
|
||||||
|
// Database operations
|
||||||
|
DBQuery(ctx context.Context, query string, args []interface{}) ([]byte, error)
|
||||||
|
DBExecute(ctx context.Context, query string, args []interface{}) (int64, error)
|
||||||
|
|
||||||
|
// Cache operations
|
||||||
|
CacheGet(ctx context.Context, key string) ([]byte, error)
|
||||||
|
CacheSet(ctx context.Context, key string, value []byte, ttlSeconds int64) error
|
||||||
|
CacheDelete(ctx context.Context, key string) error
|
||||||
|
|
||||||
|
// Storage operations
|
||||||
|
StoragePut(ctx context.Context, data []byte) (string, error)
|
||||||
|
StorageGet(ctx context.Context, cid string) ([]byte, error)
|
||||||
|
|
||||||
|
// PubSub operations
|
||||||
|
PubSubPublish(ctx context.Context, topic string, data []byte) error
|
||||||
|
|
||||||
|
// WebSocket operations (only valid in WS context)
|
||||||
|
WSSend(ctx context.Context, clientID string, data []byte) error
|
||||||
|
WSBroadcast(ctx context.Context, topic string, data []byte) error
|
||||||
|
|
||||||
|
// HTTP operations
|
||||||
|
HTTPFetch(ctx context.Context, method, url string, headers map[string]string, body []byte) ([]byte, error)
|
||||||
|
|
||||||
|
// Context operations
|
||||||
|
GetEnv(ctx context.Context, key string) (string, error)
|
||||||
|
GetSecret(ctx context.Context, name string) (string, error)
|
||||||
|
GetRequestID(ctx context.Context) string
|
||||||
|
GetCallerWallet(ctx context.Context) string
|
||||||
|
|
||||||
|
// Job operations
|
||||||
|
EnqueueBackground(ctx context.Context, functionName string, payload []byte) (string, error)
|
||||||
|
ScheduleOnce(ctx context.Context, functionName string, runAt time.Time, payload []byte) (string, error)
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
LogInfo(ctx context.Context, message string)
|
||||||
|
LogError(ctx context.Context, message string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Deployment Types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DeployRequest represents a request to deploy a function.
|
||||||
|
type DeployRequest struct {
|
||||||
|
Definition *FunctionDefinition `json:"definition"`
|
||||||
|
Source io.Reader `json:"-"` // Go source code or WASM bytes
|
||||||
|
IsWASM bool `json:"is_wasm"` // True if Source contains WASM bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployResult represents the result of a deployment.
|
||||||
|
type DeployResult struct {
|
||||||
|
Function *Function `json:"function"`
|
||||||
|
WASMCID string `json:"wasm_cid"`
|
||||||
|
Triggers []string `json:"triggers,omitempty"`
|
||||||
|
}
|
||||||
332
pkg/serverless/websocket.go
Normal file
332
pkg/serverless/websocket.go
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
package serverless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure WSManager implements WebSocketManager interface.
|
||||||
|
var _ WebSocketManager = (*WSManager)(nil)
|
||||||
|
|
||||||
|
// WSManager manages WebSocket connections for serverless functions.
|
||||||
|
// It handles connection registration, message routing, and topic subscriptions.
|
||||||
|
type WSManager struct {
|
||||||
|
// connections maps client IDs to their WebSocket connections
|
||||||
|
connections map[string]*wsConnection
|
||||||
|
connectionsMu sync.RWMutex
|
||||||
|
|
||||||
|
// subscriptions maps topic names to sets of client IDs
|
||||||
|
subscriptions map[string]map[string]struct{}
|
||||||
|
subscriptionsMu sync.RWMutex
|
||||||
|
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsConnection wraps a WebSocket connection with metadata.
|
||||||
|
type wsConnection struct {
|
||||||
|
conn WebSocketConn
|
||||||
|
clientID string
|
||||||
|
topics map[string]struct{} // Topics this client is subscribed to
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// GorillaWSConn wraps a gorilla/websocket.Conn to implement WebSocketConn.
|
||||||
|
type GorillaWSConn struct {
|
||||||
|
*websocket.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure GorillaWSConn implements WebSocketConn.
|
||||||
|
var _ WebSocketConn = (*GorillaWSConn)(nil)
|
||||||
|
|
||||||
|
// WriteMessage writes a message to the WebSocket connection.
|
||||||
|
func (c *GorillaWSConn) WriteMessage(messageType int, data []byte) error {
|
||||||
|
return c.Conn.WriteMessage(messageType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMessage reads a message from the WebSocket connection.
|
||||||
|
func (c *GorillaWSConn) ReadMessage() (messageType int, p []byte, err error) {
|
||||||
|
return c.Conn.ReadMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the WebSocket connection.
|
||||||
|
func (c *GorillaWSConn) Close() error {
|
||||||
|
return c.Conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSManager creates a new WebSocket manager.
|
||||||
|
func NewWSManager(logger *zap.Logger) *WSManager {
|
||||||
|
return &WSManager{
|
||||||
|
connections: make(map[string]*wsConnection),
|
||||||
|
subscriptions: make(map[string]map[string]struct{}),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a new WebSocket connection.
|
||||||
|
func (m *WSManager) Register(clientID string, conn WebSocketConn) {
|
||||||
|
m.connectionsMu.Lock()
|
||||||
|
defer m.connectionsMu.Unlock()
|
||||||
|
|
||||||
|
// Close existing connection if any
|
||||||
|
if existing, exists := m.connections[clientID]; exists {
|
||||||
|
_ = existing.conn.Close()
|
||||||
|
m.logger.Debug("Closed existing connection", zap.String("client_id", clientID))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.connections[clientID] = &wsConnection{
|
||||||
|
conn: conn,
|
||||||
|
clientID: clientID,
|
||||||
|
topics: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Debug("Registered WebSocket connection",
|
||||||
|
zap.String("client_id", clientID),
|
||||||
|
zap.Int("total_connections", len(m.connections)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister removes a WebSocket connection and its subscriptions.
|
||||||
|
func (m *WSManager) Unregister(clientID string) {
|
||||||
|
m.connectionsMu.Lock()
|
||||||
|
conn, exists := m.connections[clientID]
|
||||||
|
if exists {
|
||||||
|
delete(m.connections, clientID)
|
||||||
|
}
|
||||||
|
m.connectionsMu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from all subscriptions
|
||||||
|
m.subscriptionsMu.Lock()
|
||||||
|
for topic := range conn.topics {
|
||||||
|
if clients, ok := m.subscriptions[topic]; ok {
|
||||||
|
delete(clients, clientID)
|
||||||
|
if len(clients) == 0 {
|
||||||
|
delete(m.subscriptions, topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.subscriptionsMu.Unlock()
|
||||||
|
|
||||||
|
// Close the connection
|
||||||
|
_ = conn.conn.Close()
|
||||||
|
|
||||||
|
m.logger.Debug("Unregistered WebSocket connection",
|
||||||
|
zap.String("client_id", clientID),
|
||||||
|
zap.Int("remaining_connections", m.GetConnectionCount()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends data to a specific client.
|
||||||
|
func (m *WSManager) Send(clientID string, data []byte) error {
|
||||||
|
m.connectionsMu.RLock()
|
||||||
|
conn, exists := m.connections[clientID]
|
||||||
|
m.connectionsMu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return ErrWSClientNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.mu.Lock()
|
||||||
|
defer conn.mu.Unlock()
|
||||||
|
|
||||||
|
if err := conn.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||||
|
m.logger.Warn("Failed to send WebSocket message",
|
||||||
|
zap.String("client_id", clientID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends data to all clients subscribed to a topic.
|
||||||
|
func (m *WSManager) Broadcast(topic string, data []byte) error {
|
||||||
|
m.subscriptionsMu.RLock()
|
||||||
|
clients, exists := m.subscriptions[topic]
|
||||||
|
if !exists || len(clients) == 0 {
|
||||||
|
m.subscriptionsMu.RUnlock()
|
||||||
|
return nil // No subscribers, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy client IDs to avoid holding lock during send
|
||||||
|
clientIDs := make([]string, 0, len(clients))
|
||||||
|
for clientID := range clients {
|
||||||
|
clientIDs = append(clientIDs, clientID)
|
||||||
|
}
|
||||||
|
m.subscriptionsMu.RUnlock()
|
||||||
|
|
||||||
|
// Send to all subscribers
|
||||||
|
var sendErrors int
|
||||||
|
for _, clientID := range clientIDs {
|
||||||
|
if err := m.Send(clientID, data); err != nil {
|
||||||
|
sendErrors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Debug("Broadcast message",
|
||||||
|
zap.String("topic", topic),
|
||||||
|
zap.Int("recipients", len(clientIDs)),
|
||||||
|
zap.Int("errors", sendErrors),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe adds a client to a topic.
|
||||||
|
func (m *WSManager) Subscribe(clientID, topic string) {
|
||||||
|
// Add to connection's topic list
|
||||||
|
m.connectionsMu.RLock()
|
||||||
|
conn, exists := m.connections[clientID]
|
||||||
|
m.connectionsMu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.mu.Lock()
|
||||||
|
conn.topics[topic] = struct{}{}
|
||||||
|
conn.mu.Unlock()
|
||||||
|
|
||||||
|
// Add to topic's client list
|
||||||
|
m.subscriptionsMu.Lock()
|
||||||
|
if m.subscriptions[topic] == nil {
|
||||||
|
m.subscriptions[topic] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
m.subscriptions[topic][clientID] = struct{}{}
|
||||||
|
m.subscriptionsMu.Unlock()
|
||||||
|
|
||||||
|
m.logger.Debug("Client subscribed to topic",
|
||||||
|
zap.String("client_id", clientID),
|
||||||
|
zap.String("topic", topic),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a client from a topic.
|
||||||
|
func (m *WSManager) Unsubscribe(clientID, topic string) {
|
||||||
|
// Remove from connection's topic list
|
||||||
|
m.connectionsMu.RLock()
|
||||||
|
conn, exists := m.connections[clientID]
|
||||||
|
m.connectionsMu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
conn.mu.Lock()
|
||||||
|
delete(conn.topics, topic)
|
||||||
|
conn.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from topic's client list
|
||||||
|
m.subscriptionsMu.Lock()
|
||||||
|
if clients, ok := m.subscriptions[topic]; ok {
|
||||||
|
delete(clients, clientID)
|
||||||
|
if len(clients) == 0 {
|
||||||
|
delete(m.subscriptions, topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.subscriptionsMu.Unlock()
|
||||||
|
|
||||||
|
m.logger.Debug("Client unsubscribed from topic",
|
||||||
|
zap.String("client_id", clientID),
|
||||||
|
zap.String("topic", topic),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectionCount returns the number of active connections.
|
||||||
|
func (m *WSManager) GetConnectionCount() int {
|
||||||
|
m.connectionsMu.RLock()
|
||||||
|
defer m.connectionsMu.RUnlock()
|
||||||
|
return len(m.connections)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTopicSubscriberCount returns the number of subscribers for a topic.
|
||||||
|
func (m *WSManager) GetTopicSubscriberCount(topic string) int {
|
||||||
|
m.subscriptionsMu.RLock()
|
||||||
|
defer m.subscriptionsMu.RUnlock()
|
||||||
|
if clients, exists := m.subscriptions[topic]; exists {
|
||||||
|
return len(clients)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientTopics returns all topics a client is subscribed to.
|
||||||
|
func (m *WSManager) GetClientTopics(clientID string) []string {
|
||||||
|
m.connectionsMu.RLock()
|
||||||
|
conn, exists := m.connections[clientID]
|
||||||
|
m.connectionsMu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.mu.Lock()
|
||||||
|
defer conn.mu.Unlock()
|
||||||
|
|
||||||
|
topics := make([]string, 0, len(conn.topics))
|
||||||
|
for topic := range conn.topics {
|
||||||
|
topics = append(topics, topic)
|
||||||
|
}
|
||||||
|
return topics
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected checks if a client is connected.
|
||||||
|
func (m *WSManager) IsConnected(clientID string) bool {
|
||||||
|
m.connectionsMu.RLock()
|
||||||
|
defer m.connectionsMu.RUnlock()
|
||||||
|
_, exists := m.connections[clientID]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all connections and cleans up resources.
|
||||||
|
func (m *WSManager) Close() {
|
||||||
|
m.connectionsMu.Lock()
|
||||||
|
defer m.connectionsMu.Unlock()
|
||||||
|
|
||||||
|
for clientID, conn := range m.connections {
|
||||||
|
_ = conn.conn.Close()
|
||||||
|
delete(m.connections, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.subscriptionsMu.Lock()
|
||||||
|
m.subscriptions = make(map[string]map[string]struct{})
|
||||||
|
m.subscriptionsMu.Unlock()
|
||||||
|
|
||||||
|
m.logger.Info("WebSocket manager closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns statistics about the WebSocket manager.
|
||||||
|
type WSStats struct {
|
||||||
|
ConnectionCount int `json:"connection_count"`
|
||||||
|
TopicCount int `json:"topic_count"`
|
||||||
|
SubscriptionCount int `json:"subscription_count"`
|
||||||
|
TopicStats map[string]int `json:"topic_stats"` // topic -> subscriber count
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns current statistics.
|
||||||
|
func (m *WSManager) GetStats() *WSStats {
|
||||||
|
m.connectionsMu.RLock()
|
||||||
|
connCount := len(m.connections)
|
||||||
|
m.connectionsMu.RUnlock()
|
||||||
|
|
||||||
|
m.subscriptionsMu.RLock()
|
||||||
|
topicCount := len(m.subscriptions)
|
||||||
|
topicStats := make(map[string]int, topicCount)
|
||||||
|
totalSubs := 0
|
||||||
|
for topic, clients := range m.subscriptions {
|
||||||
|
topicStats[topic] = len(clients)
|
||||||
|
totalSubs += len(clients)
|
||||||
|
}
|
||||||
|
m.subscriptionsMu.RUnlock()
|
||||||
|
|
||||||
|
return &WSStats{
|
||||||
|
ConnectionCount: connCount,
|
||||||
|
TopicCount: topicCount,
|
||||||
|
SubscriptionCount: totalSubs,
|
||||||
|
TopicStats: topicStats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user