Skip to content

Scheduler & Tasks ​

Three-level priority scheduler: time-slice budget, auto-demotion, idle spin; fully async API; when to start executing game tasks (onLoad/onInit). Scheduler internals (three-level queue, auto-demotion algorithm, empty-queue spin) are described below in "Scheduler Design".

Task Configuration (TaskOptions) ​

All API methods that go through the task channel (player.sendMessage, world.setBlock, etc., all async) accept an optional task configuration object at the end of the parameters, which is passed through to the scheduler:

js
await player.sendMessage('Hello', { priority: 'high' });   // High priority
registerCommand('back', {...}, { priority: 'low' });        // For methods where options is already used, use taskOptions as the third parameter

TaskOptions: { priority?: 'high' | 'normal' | 'low' }. A few purely local getters (e.g. player.inventory) do not go through the task channel and need no task configuration.

Async Tasks ​

Async Operations (Default API) ​

js
await player.sendMessage('Hello')
await world.setBlock(0, 65, 0, 'stone')
await entity.teleport(loc)
  • Submitted via post() → $send async path
  • Tasks enter the scheduler queue and execute in a future tick
  • Does not block JS code — the JS thread can continue processing other messages
  • Subject to time-slice budget: if the current tick's HIGH/NORMAL budget is exhausted, the task is queued to the next tick
  • Cross-plugin unified scheduling: all plugins' async tasks execute in the same scheduler queue by priority
  • Returns a Promise, which resolves via the callback mechanism upon completion

Sync Mechanism (internal only, not API) ​

The API exposes only async tasks; the runtime's synchronous request/response mechanism is used solely by the framework's own control frames. Plugins that need to send their own messages can hand-write $send(channel, t, p) (frame contract: platform spec, "message channels").

Design background: Why the sync API was removed.

Practical guidance:

ScenarioApproach
Bulk repetitive work (filling blocks, batch ops)await loop or postBatch
Reading several fields at onceawait x.snap() (read-only snapshot)
Work that must finish within one tickexpress it as one task at a time (await per block); there is no "sync block"
Cancel/modify an event inside a handlerassign directly (e.cancelled = true) — collected locally, no round trip

Batch Tasks ​

Use postBatch to submit several independent tasks once and get all results back — fewer round trips:

ts
import { postBatch } from 'yeow-api';

await postBatch([
    { type: 'player.sendMessage', params: { uuid, message: 'A' } },
    { type: 'player.sendMessage', params: { uuid, message: 'B' } },
]);
  • Tasks run one by one, not atomically (a failure does not abort the rest); a failed item is {err}
  • Each item may carry priority (high/normal/low)
  • Batching does not change task semantics — it only removes JS↔runtime round trips

Selection Guide ​

ScenarioRecommendation
Sending messages, broadcasting, setting blocksAsync (default)
Reading data (health, position, world time, ...)await p.getHealth() / await world.getTime(); await p.snap() for several fields
Cancelling/modifying an event in a handlerplain assignment (e.cancelled = true, e.deathMessage = ...) — local, no round trip
Command executorsasync (async executor)
Operations that must complete as soon as possibleasync + priority: 'high'
Several independent operationsasync batch postBatch
Executing console commandsawait dispatchCommand(...)

Assigning to event fields is not a game task call — it only collects values into the write-back payload (mods), delivered once by event.complete. It is inherently local and needs no replacement.

Scheduler Design ​

Yeow's scheduler uses a three-level priority queue + time-slice budget + auto-demotion architecture. All plugins' game tasks are scheduled uniformly.

Three-Level Priority Queue ​

                ┌──────────────────┐
                │    HIGH Pool     │  ← High priority (`priority: 'high'`)
                ├──────────────────┤
                │   NORMAL Pool    │  ← Default priority (when no `priority` is given)
                ├──────────────────┤
                │     LOW Pool     │  ← Auto-demoted or manually set to `low`
                └──────────────────┘

Per-tick execution flow:

tick main-thread pump called every 50ms:

  ① First round (allocated by budget proportion)
     HIGH   50% budget → if unused, overflows to NORMAL
     NORMAL 30% budget → if unused, overflows to LOW
     LOW    20% budget → no overflow, stops when exhausted

  ② Greedy phase
     Ignores per-tier independent budgets, follows HIGH → NORMAL → LOW order
     Executes as many as possible until deadline is exhausted or all queues are empty

  ③ Idle spin
     After queues are empty, if deadline time remains, enters spin-wait (default 100μs)
     During spin, continuously checks all three queues; new tasks immediately enter the greedy phase

Per-tick total budget defaults to 20ms (tick-budget-ms: 20), configured in plugins/Yeow/runtime/config.yml. Tasks exceeding the deadline are queued to the next tick.

Priority parameter: passed in at the API layer via TaskOptions (see "Task Configuration" above); the underlying post also accepts legacy string priorities ('high' / 'normal' / 'low').

Auto-Demotion Algorithm ​

Auto-demotion prevents high-frequency NORMAL tasks from starving other tasks' execution time.

Demotion condition:
  When a NORMAL task is submitted
  → Check the call frequency of that plugin:taskType in the last 1 second
  → If it exceeds the threshold (default 200 times/second), demote priority to LOW

Demotion timing:
  Frequency is checked at submission time (submitGameSync/submitGameAsync),
  determining whether the task enters the NORMAL or LOW pool.
  Once enqueued, re-checking does not occur at execution time.

Recovery:
  When the frequency decreases, NORMAL tasks of that taskType are no longer demoted,
  naturally returning to the NORMAL pool for execution.

The frequency tracker TaskFrequencyTracker uses a sliding window algorithm:

1 second = 50 time slots, each 20ms

On each NORMAL task call:
  ① Calculate the time slot index for the current time
  ② If the slot is expired (>20ms since last update), reset to 0
  ③ Increment the slot counter
  ④ Sum all slot counts across the past 1 second
  ⑤ If total exceeds the threshold (default 200), return true (should demote)

The sliding window approach offers these advantages: no historical data retention, fixed 1-second window size, constant memory footprint (50 ints + 50 longs), and fast, stable computation.

Empty Queue Extra Wait Mechanism ​

idle-spin-us: 100 (maximum wait time, default 100 microseconds) addresses the following scenario:

JS thread submits task → Main thread just finished clearing the queue ← Time gap < 0.1ms
                           ↓
                  Without spin: waits 50ms for next tick
                  With spin: detects the new task within 100μs and executes immediately

Our empty-queue extra wait mechanism can, in the vast majority of scenarios, effectively improve resource utilization efficiency for Yeow plugins in certain common situations (up to 500x) while keeping server load stable.

For example:

js
for(loc of locs){
  await world.setBlock(...loc, blockType);
}

Without the empty-queue extra wait mechanism, when Yeow scheduler load is low, the task execution efficiency of the above code is (1 task/tick, 20 task/s); with the empty-queue extra wait mechanism, task execution efficiency can reach (10000+ task/s).

Sync Call Flow (internal) ​

$send('task', 'player.getPing', { params /* no cb */ })
  → PluginThread: scheduler.submitGameSync(taskId, …, future)
  → future.get(task-sync-timeout-ms) [JS thread blocks]
  → game thread tick(): pops from the priority pool → Tasks.execute()
  → future.complete(result) [JS thread resumes]

Not plugin API: *Sync methods and sync accessors are gone (see Why the sync API was removed). Only framework control frames and the hand-written $send use this path.

Async Call Flow ​

post('player.sendMessage', {...})
  → $send('task', '...', {..., cb:"cb_1"})
  → PluginThread: scheduler.submitGameAsync(pld, cbId, callback, priority, name)
  → Returns immediately [JS thread not blocked]
  → Main thread tick(): executes → callback.accept(result)
    → queue.sendJs($hm(t="cb", p="cb_1", r=result))
  → Message loop receives → Promise resolve

fs / http / assets ​

These do not go through the main thread; PluginThread handles them directly:

$send('fs', 'readFile', {"path":"config.json"})
  → PluginThread.handleFs() → java.nio.file
  → Returns synchronously

$send('assets', 'read', {"path":"assets/config.a1b2c3d4.yml"})
  → PluginThread.handleAssets() → ZipFile reads the corresponding entry inside the JAR
  → Returns synchronously

Path safety: plugin-level access is restricted to plugins/<pluginName>/ and blocks ../ traversal; server / outer levels require the corresponding permissions (see the fs specification).

Resource namespaces: at build time, each dependency (the main project and qualifying npm packages) is assigned a unique namespace id for its assets/, and contents are copied verbatim into the JAR assets/<id>/ (no hash renaming, so relative references are always valid). The JS side should always use getAssetsPath() (from yeow-dev) to obtain paths rather than hardcoding. See Assets API.