Add a queue and a cron schedule
Your import job runs one file at a time. A migration does not: the day you backfill Dynamics with SAP's historical exports, twenty files land in the hand-off folder at once, and until cutover the two systems only stay aligned if a full re-sync runs every night whether or not a file arrived. This chapter adds the two pieces that make both durable: a queue provider and a worker concurrency so bursts drain in parallel, and a cron schedule that fires on a cadence rather than an event.
What you will build
By the end of this chapter your workers config will name a queue provider and a worker
concurrency so bursts of imports drain in parallel instead of one-by-one, and a new
scheduled trigger (defineScheduledTrigger) will enqueue a re-sync job on a cron cadence. You
will also know the one concurrency naming gotcha to set explicitly so it does not bite you under
Aspire.
Before you begin
You need the my-erp/ workspace from Chapter 2 with the
import-products job and product-import-trigger working, and aspire start healthy. Confirm the job
is registered:
curl '<workers-endpoint>/api/v1/workers/jobs'
Expected: import-products appears in the list. If not, return to Chapter 2 and re-run
netscript generate plugins.
Step 1 — Choose a queue provider
A NetScript queue is provider-agnostic: the same job-enqueue path runs on the zero-config Deno KV
default on your laptop and on a real broker under Aspire, without touching your job code. The
QueueProvider enum has four selectable production backends, and auto-discovery picks one for you
when you do not pin one:
| Provider | How you select it | Reach for it when |
|---|---|---|
deno-kv |
Auto-discovery fallback, or pin QueueProvider.DenoKv |
Local dev and single-instance apps with no broker. Durable to the Deno KV store; needs --unstable-kv. |
redis |
Auto-discovered when a Redis/Garnet connection is present; or pin QueueProvider.Redis |
High-throughput production with a Redis/Garnet resource already provisioned by Aspire. |
rabbitmq |
Auto-discovered first when an AMQP broker is present; or pin QueueProvider.RabbitMQ |
Broker-grade routing and reliability; the top of the auto-discovery probe. |
postgres |
Explicit only — pass QueueProvider.Postgres; never auto-discovered |
You already run Postgres and want the queue in the same transactional store. Row-claim via FOR UPDATE SKIP LOCKED. |
For the ERP sync, the right default is to let the environment decide: write provider-neutral config
and you get Deno KV locally, and the Aspire cache once it is up — redis by default, or garnet via --cache-backend. The auto-discovery probe order is
RabbitMQ → Redis → Deno KV.
Step 2 — Size worker concurrency in config
concurrency on the workers config is the size of the worker pool the runner spins up — each slot is
its own V8 isolate (~20–40 MB), so raising it buys parallelism at a memory cost. This is where a
burst of imports gets drained in parallel. Set it in the generated worker config:
// config/official-plugins/mod.ts
import { defineWorkers } from '@netscript/plugin-workers-core/config';
export const workers = defineWorkers({
jobsDir: './workers/jobs',
tasksDir: './workers/tasks',
queueProvider: 'auto', // Deno KV locally; the Aspire cache once up (redis default, garnet alt).
queueName: 'jobs',
concurrency: 4, // pool size: 4 isolates → ~80–160 MB. Raise for throughput, lower to bound memory.
enabled: true,
groups: [],
});
| Name | Type | Description |
|---|---|---|
concurrency |
number |
Default worker pool size (V8 isolates running jobs in parallel). Schema default 2. |
queueProvider |
'auto' | 'deno-kv' | 'redis' | 'postgres' | 'amqp' |
Queue backend. 'auto' resolves one for you. Default 'auto'. |
queueName |
string |
Queue the runner consumes from. Default 'jobs'. |
jobsDir / tasksDir |
string |
Directories scanned for default-exported job and task modules. |
groups |
WorkerGroupData[] |
Per-topic worker groups, each with its own scaling.concurrency and retention. |
enabled |
boolean |
Whether workers run at all. Default true. |
For per-topic control — a hot imports topic at concurrency 10 while a heavy reports topic stays
at 1 — use a WorkerGroup with its own scaling: { mode, concurrency }. The full per-topic and
runner-mode knobs are in Tune the worker runtime.
Step 3 — Add a cron schedule
Some migration work is time-driven, not file-driven: the nightly full re-sync that keeps Dynamics
aligned with SAP until cutover, an hourly cleanup of stale staging files. That is a scheduled trigger —
defineScheduledTrigger(handler, spec) from
@netscript/plugin-triggers-core/builders. The add-scheduled verb uses the spaced
add scheduled shell syntax and can scaffold the job action already wired:
ns-triggers add scheduled daily-resync-schedule \
--cron="0 6 * * *" \
--timezone=UTC \
--job=import-products \
--description="Runs a full product re-sync every morning." \
--tags=resync,products,scheduled
The command writes triggers/daily-resync-schedule-trigger.ts and refreshes the trigger registry.
Like the file-watch trigger, its generated handler returns an array of effects; here the --job
flag supplies the enqueue action without hand-authoring the definition.
// triggers/daily-resync-schedule-trigger.ts (generated excerpt)
import { defineScheduledTrigger, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';
const importProductsJob = {
id: 'import-products' as JobDefinition<'import-products'>['id'],
name: 'Import Products',
topic: 'default',
entrypoint: './workers/jobs/import-products.ts',
} satisfies JobDefinition<'import-products'>;
export const dailyResyncSchedule = defineScheduledTrigger(
(event) => Promise.resolve([enqueueJob(importProductsJob, { payload: event.payload })]),
{
id: 'daily-resync-schedule',
cron: '0 6 * * *', // every day at 06:00
timezone: 'UTC',
description: 'Runs a full product re-sync every morning.',
tags: ['resync', 'products', 'scheduled'],
},
);
export default dailyResyncSchedule;
| Name | Type | Description |
|---|---|---|
id |
string |
Stable identifier for the schedule, used in logs and the events feed. |
cron |
string |
Standard 5-field cron expression. '0 6 * * *' = daily at 06:00; '*/5 * * * *' = every five minutes. |
timezone |
string |
IANA timezone the cron is evaluated in. Set it explicitly — 'UTC' here — so the cadence is unambiguous. |
description / tags |
string / string[] |
Metadata for discovery and the dashboard. |
Step 4 — Load the new trigger
The scaffold command already refreshed the registry. Restart aspire start (or let it hot-reload) so the triggers processor loads
daily-resync-schedule.
Verify your progress
First confirm the config type-checks with the new concurrency and provider settings:
deno task check
Expected: a clean check. Then confirm the cron schedule registered — for a fast feedback loop you can
temporarily set its cron to */2 * * * * (every two minutes), regenerate, restart Aspire, wait,
and read the executions feed:
ns-workers executions --limit=10 --json
Expected: an import-products execution appears on the cron cadence with no file dropped — the
schedule, not a file event, enqueued it. Set the cron back to 0 6 * * * when you are done testing.
- [ ]
config/official-plugins/mod.tssetsconcurrencyandqueueProvider. - [ ]
deno task checkis clean. - [ ]
daily-resync-scheduleis registered (afternetscript generate plugins+ Aspire restart). - [ ] A scheduled
import-productsexecution appears on the cron cadence with no file dropped. - [ ] You set
WORKERS_CONCURRENCYexplicitly (or rely on configconcurrency) rather than the ignored AspireWORKER_CONCURRENCY.
What you built
A workers config that names a queue provider and a worker concurrency so a SAP backfill burst
drains in parallel, and a defineScheduledTrigger cron that enqueues the nightly re-sync — plus
the knowledge to set concurrency where it actually takes effect. The sync now absorbs bursts and
runs its recurring work unattended. The last chapter runs the whole thing under Aspire.