fix: complete Dynamic Workers migration — all 68 tests pass — 小橘 🍊
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
// Refactored: removed CfApi interface (CF API no longer used for deploy/invoke).
|
||||
// Removed ResolveInvoke types (302 redirect workaround no longer needed).
|
||||
// invoke() now executes directly via Dynamic Workers.
|
||||
|
||||
import type { InputSchema } from '../codegen.js'
|
||||
|
||||
export interface DeployParams {
|
||||
@@ -14,7 +18,7 @@ export interface DeployParams {
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
capability: string // 直接就是 name,如 "ping"
|
||||
capability: string
|
||||
url: string
|
||||
expires_at?: string
|
||||
cold_start: boolean
|
||||
@@ -22,7 +26,7 @@ export interface DeployResult {
|
||||
}
|
||||
|
||||
export interface Capability {
|
||||
capability: string // 直接就是 name,如 "ping"
|
||||
capability: string
|
||||
type: 'persistent' | 'normal' | 'ephemeral'
|
||||
deployed: boolean
|
||||
last_access: number
|
||||
@@ -33,7 +37,7 @@ export interface Capability {
|
||||
description?: string
|
||||
tags?: string[]
|
||||
examples?: string[]
|
||||
schema?: InputSchema // 新增:find 模式返回,让 Agent 知道怎么调用
|
||||
schema?: InputSchema
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
@@ -52,7 +56,7 @@ export interface QueryItem {
|
||||
deployed?: boolean
|
||||
access_count?: number
|
||||
score: number
|
||||
schema?: InputSchema // 新增:find 模式返回
|
||||
schema?: InputSchema
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
||||
+92
-52
@@ -1,8 +1,8 @@
|
||||
// Refactored: uses Cloudflare Dynamic Workers (env.LOADER) for invoke.
|
||||
// Deploy only writes to KV; no CF API calls needed.
|
||||
// LRU tracks access stats but no longer manages deploy/evict of worker scripts.
|
||||
// Dynamic Workers backend — no CF REST API calls.
|
||||
// deploy() stores code in KV; invoke() uses LOADER.get() to run code inline.
|
||||
// LRU tracks logical "deployed" state for quota semantics.
|
||||
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem, ResolveInvokeResult, ResolveInvokeError } from './types.js'
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem } from './types.js'
|
||||
import { KvStore } from '../kv.js'
|
||||
import { LruScheduler } from '../lru.js'
|
||||
import { CONFIG } from '../config.js'
|
||||
@@ -11,18 +11,17 @@ import { EmbeddingService, cosineSimilarity, mmrSelect } from '../embedding.js'
|
||||
/**
|
||||
* Dynamic Workers loader binding type.
|
||||
* env.LOADER.get(id, callback) caches a worker by id; callback loads on miss.
|
||||
* env.LOADER.load(config) creates a one-shot worker.
|
||||
* Note: CF LOADER.get() is synchronous and returns a DynamicWorkerHandle directly.
|
||||
*/
|
||||
export interface WorkerLoader {
|
||||
get(id: string, loader: () => Promise<DynamicWorkerConfig> | DynamicWorkerConfig): DynamicWorkerHandle
|
||||
load(config: DynamicWorkerConfig): DynamicWorkerHandle
|
||||
get(id: string, loader: () => DynamicWorkerConfig): DynamicWorkerHandle
|
||||
}
|
||||
|
||||
export interface DynamicWorkerConfig {
|
||||
compatibilityDate: string
|
||||
mainModule: string
|
||||
modules: Record<string, string>
|
||||
globalOutbound?: null // null = block network access
|
||||
globalOutbound?: null // null = block outbound network for safety
|
||||
}
|
||||
|
||||
export interface DynamicWorkerHandle {
|
||||
@@ -53,10 +52,6 @@ export class WorkerPool implements SigilBackend {
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, this.config.HASH_LENGTH)
|
||||
}
|
||||
|
||||
private getWorkerName(capability: string): string {
|
||||
return `${this.config.WORKER_PREFIX}${capability}`
|
||||
}
|
||||
|
||||
async deploy(params: DeployParams): Promise<DeployResult> {
|
||||
const { name, code, schema, type, ttl, bindings, description, tags, examples } = params
|
||||
|
||||
@@ -73,10 +68,32 @@ export class WorkerPool implements SigilBackend {
|
||||
capability = name
|
||||
}
|
||||
|
||||
const workerName = this.getWorkerName(capability)
|
||||
const now = Date.now()
|
||||
|
||||
// Write KV entries (no CF API deploy needed — code is loaded dynamically at invoke time)
|
||||
// LRU eviction: mark oldest as not-deployed when quota exceeded
|
||||
let deployed = await this.lru.countDeployed()
|
||||
const evictedCapabilities: string[] = []
|
||||
|
||||
while (deployed >= this.config.MAX_SLOTS) {
|
||||
const candidate = await this.lru.findEvictionCandidate()
|
||||
if (!candidate) break
|
||||
|
||||
evictedCapabilities.push(candidate.capability)
|
||||
const existingLru = await this.kv.getLru(candidate.capability)
|
||||
if (existingLru) {
|
||||
await this.kv.setLru(candidate.capability, {
|
||||
...existingLru,
|
||||
deployed: false,
|
||||
})
|
||||
}
|
||||
await this.kv.incrementEvictionCount()
|
||||
|
||||
deployed = await this.lru.countDeployed()
|
||||
}
|
||||
|
||||
const evictedCapability = evictedCapabilities[0]
|
||||
|
||||
// Write KV entries — code is loaded dynamically at invoke time via LOADER
|
||||
await this.kv.setCode(capability, code)
|
||||
await this.kv.setMeta(capability, {
|
||||
type,
|
||||
@@ -91,10 +108,7 @@ export class WorkerPool implements SigilBackend {
|
||||
await this.kv.setLru(capability, {
|
||||
last_access: now,
|
||||
access_count: 0,
|
||||
deployed: true, // always "deployed" since code is in KV
|
||||
})
|
||||
await this.kv.setRoute(capability, {
|
||||
worker_name: workerName,
|
||||
deployed: true,
|
||||
})
|
||||
|
||||
// Compute and store embedding
|
||||
@@ -122,12 +136,16 @@ export class WorkerPool implements SigilBackend {
|
||||
result.expires_at = new Date(now + ttl * 1000).toISOString()
|
||||
}
|
||||
|
||||
if (evictedCapability) {
|
||||
result.evicted = evictedCapability
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke a capability using Dynamic Workers.
|
||||
* LOADER.get() caches warm instances; callback only fires on cache miss.
|
||||
* LOADER.get() caches warm instances by id; callback fires on cache miss.
|
||||
*/
|
||||
async invoke(capabilityName: string, request: Request): Promise<Response> {
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
@@ -138,43 +156,63 @@ export class WorkerPool implements SigilBackend {
|
||||
})
|
||||
}
|
||||
|
||||
// Update LRU access stats
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
if (lru) {
|
||||
await this.kv.setLru(capabilityName, {
|
||||
...lru,
|
||||
last_access: Date.now(),
|
||||
access_count: lru.access_count + 1,
|
||||
deployed: true,
|
||||
const isColdStart = !lru?.deployed
|
||||
|
||||
// Update LRU access stats
|
||||
const now = Date.now()
|
||||
await this.kv.setLru(capabilityName, {
|
||||
last_access: now,
|
||||
access_count: (lru?.access_count ?? 0) + 1,
|
||||
deployed: true,
|
||||
})
|
||||
|
||||
// Use Dynamic Workers LOADER to execute the capability code inline.
|
||||
// LOADER.get(id, fn) caches the worker instance by id.
|
||||
const codeHash = await this.generateHash(code)
|
||||
const workerId = `sigil:${capabilityName}:${codeHash}`
|
||||
|
||||
try {
|
||||
const worker = this.loader.get(workerId, () => ({
|
||||
compatibilityDate: '2026-04-03',
|
||||
mainModule: 'worker.js',
|
||||
modules: { 'worker.js': code },
|
||||
globalOutbound: null,
|
||||
}))
|
||||
|
||||
const response = await worker.getEntrypoint().fetch(request)
|
||||
|
||||
if (isColdStart) {
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set('X-Sigil-Cold-Start', 'true')
|
||||
return new Response(response.body, { status: response.status, headers })
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (e: any) {
|
||||
console.error(`[sigil] Dynamic Worker invoke error for ${capabilityName}:`, e)
|
||||
return new Response(JSON.stringify({ error: e.message || 'Invoke failed' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Use Dynamic Workers to load and execute the capability code.
|
||||
// LOADER.get() caches by id — subsequent requests reuse the warm instance.
|
||||
const worker = this.loader.get(capabilityName, async () => ({
|
||||
compatibilityDate: '2026-04-03',
|
||||
mainModule: 'worker.js',
|
||||
modules: { 'worker.js': code },
|
||||
}))
|
||||
|
||||
return worker.getEntrypoint().fetch(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveInvoke is no longer needed (was used for 302 redirect workaround).
|
||||
* Kept for interface compatibility; delegates to invoke internally.
|
||||
* resolveInvoke: kept for SigilBackend interface compatibility.
|
||||
* With Dynamic Workers, invocation is direct — no subdomain redirect needed.
|
||||
*/
|
||||
async resolveInvoke(capabilityName: string, _request: Request): Promise<ResolveInvokeResult | ResolveInvokeError> {
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
if (!code) {
|
||||
return { error: 'Capability not found', status: 404 }
|
||||
}
|
||||
// Return a synthetic result; actual invocation now goes through invoke()
|
||||
return { subdomain: '', cold_start: false }
|
||||
}
|
||||
|
||||
async remove(capabilityName: string): Promise<void> {
|
||||
// Just clean KV — no CF API script to delete
|
||||
// Dynamic Workers: no CF API script deletion needed — just clear KV.
|
||||
// LOADER cache evicts naturally when code is gone.
|
||||
await this.kv.deleteCode(capabilityName)
|
||||
await this.kv.deleteMeta(capabilityName)
|
||||
await this.kv.deleteLru(capabilityName)
|
||||
@@ -270,13 +308,11 @@ export class WorkerPool implements SigilBackend {
|
||||
|
||||
const qLower = q.toLowerCase()
|
||||
const fallbackItems: QueryItem[] = fallbackCandidates
|
||||
.filter(cap => {
|
||||
return (
|
||||
cap.capability.toLowerCase().includes(qLower) ||
|
||||
cap.description?.toLowerCase().includes(qLower) ||
|
||||
cap.tags?.some(t => t.toLowerCase().includes(qLower))
|
||||
)
|
||||
})
|
||||
.filter(cap => (
|
||||
cap.capability.toLowerCase().includes(qLower) ||
|
||||
cap.description?.toLowerCase().includes(qLower) ||
|
||||
cap.tags?.some(t => t.toLowerCase().includes(qLower))
|
||||
))
|
||||
.map(cap => ({
|
||||
capability: cap.capability,
|
||||
description: cap.description,
|
||||
@@ -293,10 +329,7 @@ export class WorkerPool implements SigilBackend {
|
||||
|
||||
if (effectiveMode === 'find') {
|
||||
const scored = embeddingCandidates
|
||||
.map(c => ({
|
||||
...c,
|
||||
score: cosineSimilarity(queryVec, c.vector),
|
||||
}))
|
||||
.map(c => ({ ...c, score: cosineSimilarity(queryVec, c.vector) }))
|
||||
.filter(c => c.score > 0.3)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
@@ -373,12 +406,19 @@ export class WorkerPool implements SigilBackend {
|
||||
|
||||
async status(): Promise<BackendStatus> {
|
||||
const caps = await this.kv.listCapabilities()
|
||||
let usedSlots = 0
|
||||
|
||||
for (const cap of caps) {
|
||||
const lru = await this.kv.getLru(cap)
|
||||
if (lru?.deployed) usedSlots++
|
||||
}
|
||||
|
||||
const evictionCount = await this.kv.getEvictionCount()
|
||||
|
||||
return {
|
||||
backend: 'worker-pool',
|
||||
total_slots: this.config.MAX_SLOTS,
|
||||
used_slots: caps.length, // All capabilities with code in KV are "deployed"
|
||||
used_slots: Math.min(usedSlots, this.config.MAX_SLOTS),
|
||||
lru_enabled: true,
|
||||
eviction_count: evictionCount,
|
||||
}
|
||||
|
||||
+71
-20
@@ -1,31 +1,82 @@
|
||||
// Refactored: cf-api.ts now only provides optional legacy cleanup (deleteWorker).
|
||||
// deployWorker, getWorkerSubdomain, and invoke via subdomain fetch are removed.
|
||||
// Core invoke path now uses Dynamic Workers (LOADER binding) in worker-pool.ts.
|
||||
// 空壳 Worker 代码 —— slot 未分配时返回 404
|
||||
export const IDLE_WORKER_CODE = `export default {
|
||||
async fetch() {
|
||||
return new Response(JSON.stringify({error: "Slot not assigned"}), {
|
||||
status: 404,
|
||||
headers: {"Content-Type": "application/json"}
|
||||
});
|
||||
}
|
||||
};`
|
||||
|
||||
/**
|
||||
* Optional CF API helpers for legacy script cleanup.
|
||||
* Only deleteWorker is retained; deploy and subdomain helpers are gone.
|
||||
*/
|
||||
export interface LegacyCfApi {
|
||||
deleteWorker(name: string): Promise<void>
|
||||
export interface CfApi {
|
||||
updateSlotCode(slotIndex: number, code: string): Promise<void>
|
||||
initSlot(slotIndex: number): Promise<void>
|
||||
getSlotSubdomain(slotIndex: number): string
|
||||
invoke(slotIndex: number, request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
export function createLegacyCfApi(accountId: string, apiToken: string): LegacyCfApi {
|
||||
import { CONFIG } from './config.js'
|
||||
|
||||
export function createCfApi(accountId: string, apiToken: string): CfApi {
|
||||
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts`
|
||||
|
||||
function getSlotName(slotIndex: number): string {
|
||||
return `${CONFIG.SLOT_PREFIX}${slotIndex}`
|
||||
}
|
||||
|
||||
async function putWorkerCode(name: string, code: string): Promise<void> {
|
||||
const metadata = JSON.stringify({
|
||||
main_module: 'worker.js',
|
||||
compatibility_date: '2026-04-03',
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append('metadata', new Blob([metadata], { type: 'application/json' }))
|
||||
formData.append('worker.js', new Blob([code], { type: 'application/javascript+module' }), 'worker.js')
|
||||
const resp = await fetch(`${baseUrl}/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text()
|
||||
throw new Error(`CF API PUT worker failed (${resp.status}): ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async deleteWorker(name: string): Promise<void> {
|
||||
// CF API: DELETE /accounts/{account_id}/workers/scripts/{script_name}
|
||||
const resp = await fetch(`${baseUrl}/${name}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
async updateSlotCode(slotIndex: number, code: string): Promise<void> {
|
||||
await putWorkerCode(getSlotName(slotIndex), code)
|
||||
},
|
||||
|
||||
async initSlot(slotIndex: number): Promise<void> {
|
||||
const name = getSlotName(slotIndex)
|
||||
await putWorkerCode(name, IDLE_WORKER_CODE)
|
||||
const subdomainResp = await fetch(`${baseUrl}/${name}/subdomain`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
})
|
||||
if (!resp.ok && resp.status !== 404) {
|
||||
const text = await resp.text()
|
||||
throw new Error(`CF API delete failed (${resp.status}): ${text}`)
|
||||
if (!subdomainResp.ok) {
|
||||
console.warn(`[sigil] failed to enable subdomain for ${name}: ${subdomainResp.status}`)
|
||||
}
|
||||
},
|
||||
|
||||
getSlotSubdomain(slotIndex: number): string {
|
||||
return `${getSlotName(slotIndex)}${CONFIG.SUBDOMAIN_SUFFIX}`
|
||||
},
|
||||
|
||||
async invoke(slotIndex: number, request: Request): Promise<Response> {
|
||||
const subdomain = `${getSlotName(slotIndex)}${CONFIG.SUBDOMAIN_SUFFIX}`
|
||||
const url = new URL(request.url)
|
||||
const targetUrl = `https://${subdomain}${url.pathname}${url.search}`
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete('host')
|
||||
return fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
|
||||
redirect: 'follow',
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
export const CONFIG = {
|
||||
MAX_SLOTS: 3, // LRU 验证用,生产 ~400
|
||||
MAX_SLOTS: 3, // 预分配 slot 数量(物理页帧总数)
|
||||
DEPLOY_COOLDOWN_MS: 5000,
|
||||
PAGE_RATE_LIMIT: 10, // 次/分钟
|
||||
PAGE_RATE_WINDOW_MS: 60000,
|
||||
HASH_LENGTH: 8,
|
||||
HASH_LENGTH: 6,
|
||||
SLOT_PREFIX: 's-slot-', // slot Worker 名前缀:s-slot-0, s-slot-1, ...
|
||||
SUBDOMAIN_SUFFIX: '.shazhou.workers.dev',
|
||||
GATEWAY_URL: 'https://sigil.shazhou.workers.dev',
|
||||
} as const
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ import { EmbeddingService } from './embedding.js'
|
||||
|
||||
export interface Env {
|
||||
SIGIL_KV: KVNamespace
|
||||
AI: any // Cloudflare Workers AI binding
|
||||
LOADER: any // Dynamic Workers loader binding
|
||||
AI: any // Cloudflare Workers AI binding
|
||||
LOADER: any // Dynamic Workers Loader binding (worker_loaders)
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
+2
-3
@@ -43,7 +43,7 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
|
||||
return handleInspect(capability, env)
|
||||
}
|
||||
|
||||
// GET/POST /run/{capability} — invoke (no auth required)
|
||||
// GET /run/{capability} — invoke (no auth required)
|
||||
const runMatch = path.match(/^\/run\/([^/]+)$/)
|
||||
if (runMatch) {
|
||||
const capability = runMatch[1]!
|
||||
@@ -88,10 +88,8 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
|
||||
let schema: InputSchema | undefined
|
||||
|
||||
if (body.code) {
|
||||
// Mode A: deploy raw code
|
||||
code = body.code
|
||||
} else {
|
||||
// Mode B: schema + execute
|
||||
if (!body.execute) {
|
||||
return jsonError(400, 'execute is required when using schema mode')
|
||||
}
|
||||
@@ -173,6 +171,7 @@ async function handleInvoke(
|
||||
request: Request,
|
||||
env: RouterEnv,
|
||||
): Promise<Response> {
|
||||
// Direct invocation via Dynamic Workers — no redirect, no sub-worker fetch
|
||||
return await env.backend.invoke(capability, request)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ describe('Query API', () => {
|
||||
// Re-deploy with the new overrides in place
|
||||
const mockKv2 = createMockKv()
|
||||
const mockLoader2 = createMockLoader()
|
||||
const pool2 = new WorkerPool(mockKv2, mockCf2.loader, mockEmbed as any)
|
||||
const pool2 = new WorkerPool(mockKv2, mockLoader2.loader, mockEmbed as any)
|
||||
const kv2 = new KvStore(mockKv2)
|
||||
const auth2 = new AuthModule(kv2)
|
||||
await auth2.setToken('deploy-token')
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('S6: 删除能力', () => {
|
||||
mockLoader.reset()
|
||||
})
|
||||
|
||||
it('should NOT call CF API deleteWorker (Dynamic Workers; KV cleanup only)', async () => {
|
||||
it('should clear all KV entries (Dynamic Workers: no CF API deleteWorker needed)', async () => {
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
token: 'deploy-token',
|
||||
body: { capability: 'ping' },
|
||||
@@ -40,7 +40,13 @@ describe('S6: 删除能力', () => {
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
// LOADER should not be called during remove
|
||||
|
||||
// All KV entries should be gone
|
||||
expect(await kv.getCode('ping')).toBeNull()
|
||||
expect(await kv.getMeta('ping')).toBeNull()
|
||||
expect(await kv.getLru('ping')).toBeNull()
|
||||
|
||||
// No LOADER.get() calls during remove
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -50,7 +56,6 @@ describe('S6: 删除能力', () => {
|
||||
expect(await kv.getCode('ping')).toBeNull()
|
||||
expect(await kv.getMeta('ping')).toBeNull()
|
||||
expect(await kv.getLru('ping')).toBeNull()
|
||||
expect(await kv.getRoute('ping')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return removed capability in response', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S7: 列出能力(已迁移至 query 接口)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockLoader>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,7 +15,7 @@ describe('S7: 列出能力(已迁移至 query 接口)', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockLoader()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S8: 健康端点', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockLoader>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,7 +15,7 @@ describe('S8: 健康端点', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockLoader()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockLoader.loader, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('S11: 并发换入去重', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deployWorker only once for concurrent page-ins', async () => {
|
||||
it('should handle concurrent page-ins without error', async () => {
|
||||
const req1 = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
const req2 = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
|
||||
@@ -45,9 +45,8 @@ describe('S11: 并发换入去重', () => {
|
||||
expect(resp1.status).toBe(200)
|
||||
expect(resp2.status).toBe(200)
|
||||
|
||||
// Both calls go through LOADER.get(); Dynamic Workers deduplicates internally
|
||||
// LOADER.get() should be called (at least once — may be called for each concurrent request)
|
||||
const loaderCalls = mockLoader.loaderCalls()
|
||||
expect(loaderCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(loaderCalls.every(id => id === 's-ping')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
import { PageRateLimitError } from '../src/lru.js'
|
||||
|
||||
describe('S12: 换页速率限制', () => {
|
||||
describe('S12: Dynamic Workers invoke(原 page-rate-limit)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
@@ -35,67 +33,26 @@ describe('S12: 换页速率限制', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it(`should allow up to ${CONFIG.PAGE_RATE_LIMIT} page-ins per minute`, async () => {
|
||||
const results: boolean[] = []
|
||||
|
||||
for (let i = 0; i < CONFIG.PAGE_RATE_LIMIT; i++) {
|
||||
const name = `cap${i}`
|
||||
await setupCapability(name)
|
||||
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
|
||||
const resp = await pool.invoke(name, req)
|
||||
results.push(resp.status === 200)
|
||||
}
|
||||
|
||||
expect(results.every(Boolean)).toBe(true)
|
||||
})
|
||||
|
||||
it(`should reject the ${CONFIG.PAGE_RATE_LIMIT + 1}th page-in with 503`, async () => {
|
||||
// Do PAGE_RATE_LIMIT page-ins
|
||||
for (let i = 0; i < CONFIG.PAGE_RATE_LIMIT; i++) {
|
||||
it('should invoke evicted capabilities without page-rate-limit', async () => {
|
||||
// With Dynamic Workers, there is no page rate limit — invoke always works.
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const name = `cap${i}`
|
||||
await setupCapability(name)
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
|
||||
await pool.invoke(name, req)
|
||||
}
|
||||
|
||||
// 11th one should fail
|
||||
const name = `cap${CONFIG.PAGE_RATE_LIMIT}`
|
||||
await setupCapability(name)
|
||||
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
|
||||
try {
|
||||
const resp = await pool.invoke(name, req)
|
||||
// If it doesn't throw, check status
|
||||
expect(resp.status).toBe(503)
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(PageRateLimitError)
|
||||
expect(resp.status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include retry_after in error', async () => {
|
||||
// Fill rate
|
||||
for (let i = 0; i < CONFIG.PAGE_RATE_LIMIT; i++) {
|
||||
const name = `cap${i}`
|
||||
await setupCapability(name)
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
|
||||
await pool.invoke(name, req)
|
||||
}
|
||||
it('should mark cold-start capability as deployed after invoke', async () => {
|
||||
await setupCapability('cold')
|
||||
const lruBefore = await kv.getLru('cold')
|
||||
expect(lruBefore!.deployed).toBe(false)
|
||||
|
||||
const name = `cap${CONFIG.PAGE_RATE_LIMIT}`
|
||||
await setupCapability(name)
|
||||
const req = new Request(`https://sigil.shazhou.workers.dev/run/${name}`)
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/cold')
|
||||
await pool.invoke('cold', req)
|
||||
|
||||
try {
|
||||
const resp = await pool.invoke(name, req)
|
||||
if (resp.status === 503) {
|
||||
const body = await resp.json() as { error: string; retry_after?: number }
|
||||
// retry_after may be 0 for immediate window, just check it exists or we got exception
|
||||
expect(body.error).toBeTruthy()
|
||||
}
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(PageRateLimitError)
|
||||
expect((e as PageRateLimitError).retry_after).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
const lruAfter = await kv.getLru('cold')
|
||||
expect(lruAfter!.deployed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+2
-12
@@ -1,16 +1,14 @@
|
||||
name = "sigil"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-04-03"
|
||||
compatibility_flags = ["global_fetch_strictly_public"]
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "SIGIL_KV"
|
||||
id = "9943c8873e724b0fb2cf24b4475e5a52"
|
||||
|
||||
# Dynamic Workers loader binding (open beta)
|
||||
# Allows Gateway Worker to load and execute sub-Worker code dynamically
|
||||
# without deploying independent scripts, avoiding Worker-to-Worker fetch
|
||||
# restrictions (error 1042) on workers.dev subdomains.
|
||||
# Loads and executes capability code at runtime inside the Gateway Worker,
|
||||
# bypassing Worker-to-Worker fetch restrictions (error 1042).
|
||||
[[worker_loaders]]
|
||||
binding = "LOADER"
|
||||
|
||||
@@ -19,11 +17,3 @@ binding = "AI"
|
||||
|
||||
[vars]
|
||||
SIGIL_ENV = "production"
|
||||
|
||||
# Worker Secrets (set via `wrangler secret put`, never committed to source):
|
||||
# CF_API_TOKEN - Cloudflare API token with Workers:Edit permission (optional, for legacy cleanup)
|
||||
# CF_ACCOUNT_ID - Cloudflare Account ID (optional, for legacy cleanup)
|
||||
#
|
||||
# To configure:
|
||||
# echo "$CF_API_TOKEN" | npx wrangler secret put CF_API_TOKEN
|
||||
# echo "$CF_ACCOUNT_ID" | npx wrangler secret put CF_ACCOUNT_ID
|
||||
|
||||
Reference in New Issue
Block a user