{"id":"iamthebestts/actor-manager","name":"actor-manager","scope":"iamthebestts","platform":"roblox","description":"High-performance generic parallel Actor orchestrator for Roblox","version":"0.2.1","latest":"0.2.1","versions":["0.1.0","0.1.1","0.1.2","0.2.0-alpha.1","0.2.0","0.2.1"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{"evaera/promise":{"version":"^4.0.0","alias":"Promise"},"sleitnick/signal":{"version":"^1.5.0","alias":"Signal"}},"integrity":"0c0811c3801a298dd7902e04ab855f687075050a708aa1e011487e6097b727c4","likes":0,"downloads":0,"install":"forest install iamthebestts/actor-manager","url":"https://forest.dev/p/roblox/iamthebestts/actor-manager","files":"https://api.forest.dev/ai/package/roblox/iamthebestts/actor-manager/files","readme":"<div align=\"center\">\n\n# ActorManager\n\n**A production-grade Parallel Luau actor pool for Roblox.**  \nOffload CPU-intensive work to real OS threads — with a Promise-based API, priority scheduling, and battle-tested lifecycle management.\n\n[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)\n[![Wally](https://img.shields.io/badge/wally-iamthebestts%2Factor--manager-orange)](https://wally.run/package/iamthebestts/actor-manager)\n[![Luau](https://img.shields.io/badge/language-Luau-blueviolet)](https://luau-lang.org)\n[![TestEZ](https://img.shields.io/badge/tested_with-TestEZ-success)](https://github.com/roblox/testez)\n[![Parallel Luau](https://img.shields.io/badge/uses-Parallel%20Luau-informational)](https://create.roblox.com/docs/scripting/multithreading)\n\n</div>\n\n---\n\n## Why ActorManager?\n\nRoblox's Parallel Luau lets scripts run on real OS threads — but wiring up Actors, handling result routing, managing worker lifecycles, and queuing tasks is error-prone boilerplate that breaks in subtle ways.\n\nActorManager handles all of that:\n\n- **No frame drops** — heavy computation runs outside the main game loop\n- **Promise-based** — `andThen`, `catch`, `Batch`, `Broadcast`; fits any existing async code\n- **Priority queue** — CRITICAL tasks are never stuck behind LOW ones\n- **SharedTable result bus** — results flow back without copying the full payload through `BindableEvent:Fire`\n- **Two-phase worker init** — tasks can't dispatch before the worker is actually ready\n- **Poisoned-worker detection** — a timed-out worker can't corrupt the next task\n\n---\n\n## Installation\n\n**Via [Wally](https://wally.run):**\n\n```toml\n[dependencies]\nActorManager = \"iamthebestts/actor-manager@0.2.0\"\n```\n\n```sh\nwally install\n```\n\n**Manual:** copy the `src/` folder into your project and `require` `init.luau`.\n\n---\n\n## Documentation\n\n- Intro: [docs/intro.md](docs/intro.md)\n- Installation: [docs/installation.md](docs/installation.md)\n- Why Use ActorManager: [docs/why-use-actor-manager.md](docs/why-use-actor-manager.md)\n- API Reference: [docs/API.md](docs/API.md)\n\n---\n\n## Quick Start\n\n**1. Create your worker** (`EchoWorker.server.luau`):\n\n```lua\nif not script:GetActor() then return end\n\nlocal defineWorker = require(game.ReplicatedStorage.Packages.ActorManager).defineWorker\n\ndefineWorker(script, {\n    add = function(payload: { a: number, b: number })\n        return payload.a + payload.b\n    end,\n\n    echo = function(payload)\n        return payload\n    end,\n})\n```\n\n**2. Spin up the pool and dispatch:**\n\n```lua\nlocal ActorManager = require(game.ReplicatedStorage.Packages.ActorManager)\n\nlocal manager = ActorManager.new({\n    workerModule = script.EchoWorker,\n    workerCount  = 4,\n    taskTimeout  = 10,\n})\n\nmanager:Dispatch(\"add\", { a = 10, b = 32 })\n    :andThen(function(result)\n        print(result) -- 42\n    end)\n    :catch(function(err)\n        warn(err)\n    end)\n```\n\n---\n\n## API\n\n### `ActorManager.new(config)`\n\n| Field | Type | Default | Description |\n| --- | --- | --- | --- |\n| `workerModule` | `Script` | **required** | Script cloned into each Actor |\n| `workerCount` | `number?` | `8` | Pool size. Roblox caps parallel threads at ~3 on live servers and ~3 on desktop clients — extra Actors above this add memory with no parallelism gain |\n| `taskTimeout` | `number?` | — | Seconds before a task is rejected as timed out |\n| `workerRecycleTimeout` | `number?` | — | Seconds to wait for a poisoned worker's late result before destroying and respawning it |\n| `maxQueueSize` | `number?` | — | Reject new tasks immediately when the queue is full |\n| `onWorkerError` | `function?` | — | `(err: string, taskId: string) -> ()` called on every worker error |\n\n---\n\n### `:Dispatch(taskName, payload, opts?)`\n\nSends a task to the next free worker. Returns a `Promise`.\n\n```lua\n-- fire and forget\nmanager:Dispatch(\"echo\", { value = 1 })\n\n-- with priority\nmanager:Dispatch(\"echo\", { value = 1 }, { priority = \"CRITICAL\" })\n\n-- await result\nmanager:Dispatch(\"add\", { a = 5, b = 5 })\n    :andThen(function(result) print(result) end) -- 10\n```\n\n**Priority levels** (highest → lowest): `CRITICAL` · `HIGH` · `NORMAL` · `LOW`\n\n---\n\n### `:Batch(tasks)`\n\nDispatches multiple tasks, resolves with an ordered array of results. Rejects entirely if any task fails.\n\n```lua\nmanager:Batch({\n    { task = \"add\", payload = { a = 1, b = 1 } },\n    { task = \"add\", payload = { a = 2, b = 2 } },\n    { task = \"add\", payload = { a = 3, b = 3 } },\n}):andThen(function(results)\n    -- { 2, 4, 6 }\nend)\n```\n\n---\n\n### `:Broadcast(taskName, payload)`\n\nSends the same task to **every** worker at once. Resolves with an array of N results (one per worker). Useful for syncing shared state.\n\n```lua\nmanager:Broadcast(\"reload_config\", newConfig)\n    :andThen(function(results)\n        print(#results .. \" workers updated\")\n    end)\n```\n\n---\n\n### `:GetStats()`\n\nReturns a snapshot of the pool. Each call returns a fresh table.\n\n```lua\nlocal stats = manager:GetStats()\n-- {\n--   freeWorkers  = 3,\n--   busyWorkers  = 1,\n--   totalWorkers = 4,\n--   queued       = 0,\n--   processed    = 142,\n--   errors       = 1,\n-- }\n```\n\n---\n\n### `:Pause()` / `:Resume()`\n\nStops dispatching without dropping the queue. Tasks accumulate and are picked up immediately on `Resume()`.\n\n---\n\n### `:Destroy()`\n\nRejects all pending and queued tasks, destroys all Actors, cleans up signals and the container Folder. Safe to call multiple times.\n\n---\n\n### Events\n\n```lua\nmanager.onError:Connect(function(err: string, taskId: string)\n    warn(\"[Worker error]\", err, taskId)\nend)\n\nmanager.onDrained:Connect(function()\n    print(\"queue empty, all workers free\")\nend)\n```\n\n---\n\n## Writing Workers\n\nWorkers are plain `Script`s cloned into Actors. Use `defineWorker` from `ActorManager`:\n\n```lua\nif not script:GetActor() then return end\n\nlocal defineWorker = require(game.ReplicatedStorage.Packages.ActorManager).defineWorker\n\ndefineWorker(script, {\n    myTask = function(payload)\n        -- heavy work here — this runs on a real OS thread\n        return result\n    end,\n})\n```\n\n> **`callerScript` must be `script`** — the Script inside the Actor, not a ModuleScript. WorkerBase needs it to find the correct Actor ancestor.\n\n### ⚠️ Parallel Luau constraints\n\nAll handlers run inside `BindToMessageParallel`:\n\n| | |\n| --- | --- |\n| ✅ Reading from the DataModel | allowed |\n| ❌ Writing to the DataModel | **not allowed** — call `task.synchronize()` first |\n| ✅ `task.wait()` | allowed |\n| ❌ Busy-wait loops | **not allowed** — blocks the thread, prevents `taskTimeout` from firing |\n\n```lua\n-- handler that needs to write to the DataModel\ndefineWorker(script, {\n    movePart = function(payload)\n        task.synchronize() -- move back to serial thread\n        workspace.Part.Position = payload.position\n        return true\n    end,\n})\n```\n\n---\n\n## Architecture\n\n```txt\nActorManager.new()\n│\n├── Folder  (ServerScriptService or PlayerScripts)\n│   ├── Actor \"Worker_1\"\n│   │   ├── BindableEvent \"_ResultBus\"\n│   │   └── Script  ← clone of workerModule\n│   └── Actor \"Worker_N\"\n│\n├── PriorityQueue      max-heap, tasks wait here when all workers are busy\n├── _pendingTasks      taskId → { resolve, reject, timer }\n└── _poisonedWorkers   workers that timed out and haven't sent their late result yet\n```\n\n**Result flow:**\n\n```txt\nDispatch()\n  └─► PriorityQueue:Push()\n        └─► worker:SendMessage(\"AM_Task\", msg)\n              └─► handler runs in parallel\n                    └─► resultStore[taskId] = data   (SharedTable write, no copy)\n                          └─► resultBus:Fire(taskId)  (just the ID string)\n                                └─► manager reads resultStore[taskId]\n                                      └─► Promise resolves / rejects\n```\n\n**Two-phase worker init** prevents tasks from arriving before the worker is ready:\n\n```txt\nWorker fires \"__WORKER_READY__\"\n  └─► Manager sends SharedTable via AM_Init\n        └─► Worker fires \"__WORKER_INIT_DONE__\"\n              └─► Worker added to free pool ✓\n```\n\n**Poisoned worker handling:** on timeout, the worker is *not* returned to the free pool. It's tracked in `_poisonedWorkers`. If the late result eventually arrives, the worker is recycled normally. If `workerRecycleTimeout` is set and nothing arrives, the worker is destroyed and a fresh one spawns in its place.\n\n---\n\n## Gotchas\n\n- **Workers must be `Script`s** (not ModuleScripts) and live inside an Actor. `defineWorker` relies on `script:GetActor()` to resolve the correct ancestor.\n- **All payloads must be `SharedTable`-serializable.** Unsupported types will error when sent across the result bus.\n- **Parallel Luau restrictions apply.** Handlers run in parallel; call `task.synchronize()` before writing to the DataModel.\n\n---\n\n## Testing\n\nTests are in `tests/` and use [TestEZ](https://github.com/roblox/testez). Run the place in Roblox Studio — results print to the Output window.\n\n```txt\ntests/\n├── fixtures/\n│   └── EchoWorker.server.luau       echo · add · fail · slow handlers\n├── ActorManager.spec.luau           unit tests (sync, no real Actors)\n├── ActorManager.integration.spec.luau  integration tests (real Actors)\n├── PriorityQueue.spec.luau          heap correctness\n└── runner.server.luau               bootstrap\n```\n\n**54 tests, 0 skipped.**\n\n---\n\n## Project Structure\n\n```txt\nsrc/\n├── init.luau           package entry point\n├── ActorManager.luau   pool orchestrator\n├── WorkerBase.luau     worker-side defineWorker helper\n├── PriorityQueue.luau  max-heap priority queue\n└── Types.luau          shared type definitions\n```\n\n---\n\nAll promises are internally caught to avoid unhandled rejections.\n\n---\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n\n---\n\n<div align=\"center\">\n\nMade by **[iamthebestts](https://github.com/iamthebestts)**\n\n</div>\n","readmeTruncated":false}