Skip to content

Synchronisation Layer

How Max syncs data from external sources into its local store.


The sync layer has three parts:

  1. Resolvers & Loaders define how to fetch data for each entity type
  2. Seeders & SyncPlans define what to sync and in what order
  3. The Executor turns a plan into a task graph and drains it
Diagram

A Loader fetches data from an external API and returns EntityInput values. Loaders receive an env parameter - a LoaderEnv that provides access to platform capabilities. Today this includes the connector context (env.ctx) and the operation executor (env.ops) - see Operations for details.

There are three variants:

VariantSignatureUse case
entity(ref) => EntityInputFetch one entity at a time
entityBatched(refs[]) => Batch<EntityInput>Fetch many entities in one API call
collection(parentRef, page) => Page<EntityInput>Paginated child entities
Diagram

Fetches fields for a single entity.

const TeamBasicLoader = Loader.entity({
name: "acme:team:basic",
context: AcmeAppContext,
entity: AcmeTeam,
async load(ref, env) {
const team = await env.ctx.api.teams.get(ref.id);
return EntityInput.create(ref, {
name: team.name,
description: team.description,
owner: AcmeUser.ref(team.ownerId),
});
},
});

Fetches fields for many entities in a single API call. Preferred when the API supports batch retrieval.

const BasicUserLoader = Loader.entityBatched({
name: "acme:user:basic",
context: AcmeAppContext,
entity: AcmeUser,
async load(refs, env) {
const users = await env.ctx.api.users.getBatch(refs.map(r => r.id));
return Batch.buildFrom(
users.map(u => EntityInput.create(AcmeUser.ref(u.id), {
name: u.name,
email: u.email,
}))
).withKey(input => input.ref);
},
});

Fetches a paginated list of child entities belonging to a parent.

const TeamMembersLoader = Loader.collection({
name: "acme:team:members",
context: AcmeAppContext,
entity: AcmeTeam,
target: AcmeUser,
async load(ref, page, env) {
const result = await env.ctx.api.teams.listMembers(ref.id, {
cursor: page.cursor,
limit: page.limit,
});
const items = result.members.map(m =>
EntityInput.create(AcmeUser.ref(m.userId), {})
);
return Page.from(items, result.hasMore, result.nextCursor);
},
});

A Resolver maps an entity’s fields to the loaders that populate them. Each field points to exactly one loader.

Diagram
const AcmeTeamResolver = Resolver.for(AcmeTeam, {
name: TeamBasicLoader.field("name"),
description: TeamBasicLoader.field("description"),
owner: TeamBasicLoader.field("owner"),
members: TeamMembersLoader.field(),
});

Multiple fields can share a loader. When the executor needs to load name and description, it sees both map to TeamBasicLoader and makes a single call.


A Seeder bootstraps a sync from cold start. It stores an initial entity (the root) and returns a SyncPlan describing what to sync and in what order.

const AcmeSeeder = Seeder.create({
context: AcmeAppContext,
async seed(ctx, engine) {
// Store the root entry point
const rootRef = AcmeRoot.ref("root");
await engine.store(EntityInput.create(rootRef, {}));
return SyncPlan.create([
Step.forRoot(rootRef).loadCollection("teams"),
Step.forAll(AcmeTeam).loadFields("name", "description", "owner"),
Step.forAll(AcmeTeam).loadCollection("members"),
Step.forAll(AcmeUser).loadFields("name", "email"),
]);
},
});

A SyncPlan is a sequence of steps. Each step has a target (which entities) and an operation (what to load).

Targets:

TargetMeaning
forRoot(ref)A single known root entity
forOne(ref)A single known entity
forAll(EntityDef)All entities of this type in the store

Operations:

OperationMeaning
loadFields("a", "b")Load the named fields via their resolvers
loadCollection("field")Load a collection field (paginated)

Steps run sequentially by default. Each step waits for the previous step (and all its children) to finish before starting. This matters because later steps often depend on entities discovered by earlier ones.

Diagram

Steps that don’t depend on each other can run in parallel:

SyncPlan.create([
Step.forRoot(rootRef).loadCollection("teams"),
Step.concurrent([
Step.forAll(AcmeTeam).loadFields("name"),
Step.forAll(AcmeProject).loadFields("status"),
]),
]);

The SyncExecutor turns a SyncPlan into a task graph and processes it.

The PlanExpander converts each step into one or more tasks, wiring up dependencies:

Diagram

Tasks start in new (blocked) or pending (ready). When a blocking task completes, its dependents move from new to pending.

Diagram

Tasks spawn child tasks at runtime. Both loadCollection and loadFields steps produce children:

  • forAll.loadCollection creates one load-collection child per entity
  • forAll.loadFields pages through refs and creates batched load-fields children (default: 25 refs per batch)

The parent moves to awaiting_children and completes when all children finish. Because children are independent tasks, the worker pool executes them concurrently.

Diagram

Collection loaders also paginate. If a page has more results, the task spawns a continuation child with the next cursor:

Diagram

The executor runs a pool of 64 concurrent workers. Each worker independently claims tasks and executes them. A FlowController at the executor level gates how many tasks actually run in parallel (default: 50 concurrent tasks via a semaphore).

Diagram

Workers coordinate through a Signal primitive rather than polling. When no tasks are available, a worker calls Signal.wait() and blocks at zero CPU cost. When a task completes, spawns children, or fails, it calls Signal.notifyAll() to wake all idle workers immediately.

When a task completes:

  1. Tasks blocked by it (blockedBy) move from new to pending
  2. If all siblings of a parent are complete, the parent completes too (walking up the parent chain iteratively)

Task execution is gated at two levels:

LayerWhat it limitsDefault
Executor-level FlowControllerHow many tasks run concurrently50 (semaphore)
Operation-level LimitHow many API calls for a given limit run concurrentlyPer-connector (e.g., 50 for acme:api)

The executor-level controller prevents overwhelming the system with too many concurrent tasks. The operation-level controller (enforced via middleware) prevents overwhelming external APIs. Both are FlowController implementations - the executor’s is configured at wiring time, the operation’s is declared per-connector via Limit.concurrent().

When a task fails, the drain loop marks it as failed and moves on. The loop exits when no tasks are pending or running - tasks left in new (blocked by a failed task) or awaiting_children (with a failed child) remain in those states.

The SyncResult reports both tasksCompleted and tasksFailed, so the caller knows the sync wasn’t clean.

Diagram

The stranded tasks preserve honest state: they weren’t cancelled or failed - they were never attempted. A future resume operation could retry the failed task, which would naturally unblock the rest.

execute() returns a SyncHandle immediately. The sync runs in the background.

const handle = executor.execute(plan);
// Monitor
const status = await handle.status(); // "running" | "paused" | "completed" | ...
// Control
await handle.pause();
await handle.resume();
await handle.cancel();
// Wait for result
const result = await handle.completion();
// { status: "completed", tasksCompleted: 12, tasksFailed: 0, duration: 1540 }

Putting it all together for the Acme connector:

Diagram
  • Error recovery / resume - When a sync has failures, stranded tasks remain in honest states. A resume mechanism could retry failed tasks and naturally unblock the rest. The state model supports this; the trigger mechanism doesn’t exist yet.

  • Cross-step deduplication - If step 3 discovers users u1, u2, u3 and step 4 also needs them, they’re loaded independently. Deduplication across steps would coalesce these into a single load.


ConceptRole
EntityDefSchema for an entity type (fields + types)
RefTyped pointer to an entity instance
LoaderFetches data from an external API
ResolverMaps entity fields to loaders
SeederBootstraps a sync from cold start
SyncPlanOrdered list of steps to execute
StepTarget (which entities) + operation (what to load)
TaskSerialisable unit of work in the execution layer
SyncExecutorOrchestrates the worker pool and task lifecycle
TaskRunnerExecutes a single task (calls loaders, stores results)
FlowControllerGates concurrency (at executor and operation levels)
SignalWait/notify primitive for worker coordination
TaskStorePersists task state (supports restart/resume)
SyncHandleControl and monitor a running sync