Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/edge-workers/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.PHONY: build run test clean deploy-example

BIN := edge-workers

build:
go build -o $(BIN) .

run: build
./$(BIN)

test:
go test ./...

clean:
rm -f $(BIN)

# Deploy a worker against a running instance (control plane on :8090).
# Usage: make deploy-example
deploy-example:
curl -s -X POST http://localhost:8090/workers \
-H 'Content-Type: application/json' \
-d '{"name":"hello-php","route":"/hello","runtime":"php","script":"'"$$(pwd)"'/workers/hello.php","env":{"GREETING":"Xin chào"}}'
132 changes: 132 additions & 0 deletions apps/edge-workers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# edge-workers

A **Cloudflare Workers-style serverless edge runtime** built on the Fluxor
verticle model. Deploy small scripts ("workers") bound to routes; each request
runs the script in a short-lived, isolated subprocess and returns its response.

## Architecture / flow

```
HTTP / IO CPU layer engine logic
(net/http) (event loop · kqueue · verticle) (subprocess) (script)
┌──────────┐ req ┌───────────────────────────┐ ┌────────────┐ ┌─────────┐
│ gateway │ ──────▶ │ EdgeVerticle │ │ PHP engine │ │ stdin → │
│ :8080 │ │ 1. RunOnEventLoop: │ ─▶ │ Node engine│ ─▶ │ logic │
│ │ ◀────── │ match route (<20µs) │ │ │ │ → stdout│
└──────────┘ resp │ 2. SubmitBlockingFunc: │ ◀─ └────────────┘ ◀─ └─────────┘
│ run engine on pool │
└───────────────────────────┘
```

- **HTTP / IO** — the gateway only parses the request and hands off; it does no work.
- **CPU layer** — the verticle's **event loop** does the cheap, sequential route
match (race-free, `<20µs`), then offloads the blocking script execution to the
**worker pool** via `SubmitBlockingFunc`, so the event loop never blocks. This is
the standard Fluxor / Vert.x split.
- **Engines** — lightweight language runtimes invoked as subprocesses: **PHP** and
**Node**. A JVM-class engine is deliberately *not* provided — too heavy for a
per-request edge model.
- **Scripts handle logic only — no HTTP.** A worker never opens a socket or starts
a server. It reads the request as JSON on **stdin**, computes, and writes the
response as JSON on **stdout**. All networking lives in the Go gateway.

## The worker contract

Request delivered on **stdin**:

```json
{ "method": "GET", "path": "/hello", "query": "name=Khang",
"headers": {"...":"..."}, "body": "", "worker": "hello-php" }
```

Response written to **stdout** (raw text is also accepted → `200 text/plain`):

```json
{ "status": 200, "headers": {"Content-Type": "application/json"}, "body": "..." }
```

Per-worker bindings (`env`) are injected as process environment variables.

### KV bindings (logic-only, no network)

A worker declares KV bindings (`bindingName → namespace`). On each invocation the
runtime snapshots the bound namespaces into the request, and applies any mutations
the worker returns — so scripts get durable cross-request state without opening a
socket:

```jsonc
// request adds: "kv": { "COUNTERS": { "visits": "41" } }
// response adds: "kv": [ { "binding": "COUNTERS", "op": "put", "key": "visits", "value": "42", "ttl": 0 } ]
// ("op": "delete" removes a key; "ttl" is seconds, 0 = no expiry)
```

### Cron triggers

A worker with a `cron` field (standard 5-field expression) is scheduled via Fluxor's
scheduler. On each tick the runtime synthesises a request with `"trigger":"scheduled"`
(and `"method":"SCHEDULED"`) and runs the worker through the same event-loop →
worker-pool path. The same script can serve both HTTP and scheduled triggers by
inspecting `request.trigger`.

## Run

```bash
make run # builds and starts the runtime
```

- Data plane (gateway): `http://localhost:8080/<route>`
- Control plane (admin): `http://localhost:8090/workers`

Four example workers are seeded on start:

| Worker | Route | Runtime | Feature |
|------------------|--------------|---------|---------------------------------|
| `hello.php` | `/hello` | PHP | env binding (`GREETING`) |
| `echo.js` | `/echo` | Node | request echo |
| `counter.php` | `/counter` | PHP | **KV binding** (visit counter) |
| `heartbeat.js` | `/heartbeat` | Node | **cron trigger** + KV state |

```bash
curl 'http://localhost:8080/hello?name=Khang' # PHP worker (env binding)
curl -X POST http://localhost:8080/echo -d '{"hi":1}' # Node worker
curl http://localhost:8080/counter # KV: increments each hit
curl http://localhost:8080/heartbeat # reports cron tick count
```

## Management API (deploy at runtime)

```bash
# List
curl http://localhost:8090/workers

# Deploy (or replace) a worker — env, KV bindings, and cron are all optional
curl -X POST http://localhost:8090/workers -H 'Content-Type: application/json' -d '{
"name": "hello-php", "route": "/hello", "runtime": "php",
"script": "/abs/path/workers/hello.php",
"env": {"GREETING": "Hi"},
"kv": {"COUNTERS": "counters"},
"cron": "*/5 * * * *"
}'

# Delete
curl -X DELETE http://localhost:8090/workers/hello-php
```

## Layout

| File | Responsibility |
|---------------|-----------------------------------------------------------|
| `engine.go` | Engine interface + PHP/Node subprocess engines |
| `worker.go` | Worker model, stdin/stdout JSON contract, route registry |
| `kv.go` | In-memory KV store (namespaces + TTL) + write-back ops |
| `verticle.go` | `EdgeVerticle` — event-loop dispatch + worker-pool offload |
| `cron.go` | `CronManager` — cron triggers via `pkg/scheduler` |
| `server.go` | HTTP gateway (data plane) + management API (control plane) |
| `main.go` | Bootstrap, engine detection, example seeding |
| `workers/` | Example scripts (`hello.php`, `echo.js`, `counter.php`, `heartbeat.js`) |

## Adding a worker

1. Drop a script in `workers/` that reads stdin / writes stdout per the contract.
2. `POST /workers` with its `route`, `runtime`, and absolute `script` path.
3. Requests matching the route now execute it. No restart needed.
8 changes: 8 additions & 0 deletions apps/edge-workers/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "edge-workers",
"description": "Cloudflare Workers-style serverless edge runtime on Fluxor",
"edgeAddr": ":8080",
"adminAddr": ":8090",
"engineTimeout": "10s",
"engines": ["php", "node"]
}
134 changes: 134 additions & 0 deletions apps/edge-workers/cron.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2024-2028 Fluxor Framework
// All rights reserved.
//
// Cron triggers — schedule a worker to run on a cron expression, the edge-workers
// analogue of Cloudflare Workers Cron Triggers.
//
// A scheduled invocation reuses the exact same execution path as an HTTP request:
// the runtime synthesises a WorkerRequest with Trigger="scheduled" and feeds it to
// the verticle (event loop → worker pool → engine → script). The script handles it
// as ordinary logic — it inspects request.trigger and acts accordingly.

package main

import (
"context"
"fmt"
"strings"
"sync"
"time"

"github.com/fluxorio/fluxor/pkg/core"
"github.com/fluxorio/fluxor/pkg/core/concurrency"
"github.com/fluxorio/fluxor/pkg/scheduler"
)

// CronManager binds workers' cron expressions to the Fluxor scheduler and fires
// scheduled invocations through the verticle.
type CronManager struct {
sched scheduler.Scheduler
verticle *EdgeVerticle
registry *Registry
logger core.Logger

mu sync.Mutex
ids map[string]string // worker name -> scheduler task id
}

// NewCronManager creates a cron manager bound to the given context lifetime.
func NewCronManager(ctx context.Context, v *EdgeVerticle, r *Registry) *CronManager {
return &CronManager{
sched: scheduler.NewScheduler(ctx),
verticle: v,
registry: r,
logger: core.NewDefaultLogger(),
ids: make(map[string]string),
}
}

// Start begins firing scheduled tasks.
func (c *CronManager) Start(ctx context.Context) error {
return c.sched.Start(ctx)
}

// Stop halts the scheduler.
func (c *CronManager) Stop() error {
return c.sched.Stop()
}

// Register schedules a worker if it declares a cron expression. Calling it again
// for the same worker first cancels the previous schedule (deploy = replace).
// A worker without a Cron field is a no-op.
func (c *CronManager) Register(w *Worker) error {
if strings.TrimSpace(w.Cron) == "" {
return nil
}
c.Unregister(w.Name)

name := w.Name
task := concurrency.NewNamedTask("cron:"+name, func(ctx context.Context) error {
return c.fire(ctx, name)
})

spec, err := safeCronSpec(w.Cron)
if err != nil {
return fmt.Errorf("worker %q: %w", name, err)
}
id, err := c.sched.Schedule(task, spec)
if err != nil {
return fmt.Errorf("schedule worker %q with cron %q: %w", name, w.Cron, err)
}

c.mu.Lock()
c.ids[name] = id
c.mu.Unlock()
c.logger.Info(fmt.Sprintf("cron registered: %q → %q", name, w.Cron))
return nil
}

// Unregister cancels a worker's schedule if present.
func (c *CronManager) Unregister(name string) {
c.mu.Lock()
id, ok := c.ids[name]
delete(c.ids, name)
c.mu.Unlock()
if ok {
_ = c.sched.Unschedule(id)
}
}

// safeCronSpec builds a cron schedule, converting scheduler.Cron's fail-fast
// panic on a malformed expression into an ordinary error so a bad deploy request
// yields a clean 400 instead of aborting the connection.
func safeCronSpec(expr string) (spec scheduler.ScheduleSpec, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("invalid cron expression %q: %v", expr, r)
}
}()
return scheduler.Cron(expr), nil
}

// fire looks the worker up fresh (it may have been replaced) and dispatches a
// synthetic scheduled request through the verticle.
func (c *CronManager) fire(ctx context.Context, name string) error {
worker := c.registry.Get(name)
if worker == nil {
c.Unregister(name) // worker was deleted — stop firing
return nil
}

runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

req := WorkerRequest{
Method: "SCHEDULED",
Path: worker.Route,
Trigger: "scheduled",
Cron: worker.Cron,
Headers: map[string]string{},
}
resp := c.verticle.RunWorker(runCtx, worker, req)
c.logger.Info(fmt.Sprintf("cron fired: %q (status %d)", name, resp.Status))
return nil
}
Loading