Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app-compose/knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"ignore": ["**/__tests__/**", "*.config.ts"],
"entry": ["./src/index.ts!"],
"ignoreBinaries": ["tsdown", "knip", "oxlint", "vitest"],
"vitest": { "config": "vitest.config.ts", "entry": "src/**/*.{test,test-d}.ts" }
"vitest": { "config": "vitest.config.ts", "entry": "src/**/*.{test,test-d,bench}.ts" }
}
1 change: 1 addition & 0 deletions packages/app-compose/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"build": "tsdown",
"dev": "tsdown --watch",
"prepack": "pnpm build",
"bench": "vitest bench",
"test": "vitest run",
"lint": "knip && oxlint ./src"
},
Expand Down
94 changes: 94 additions & 0 deletions packages/app-compose/src/compose/__tests__/compose.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { literal, shape, type Spot } from "@computable"
import { bind, createTag, createTask } from "@runnable"
import { bench, describe, vi } from "vitest"
import { compose } from "../compose"

const double = (x: number) => x * 2

describe("multi layer, compute shapes", () => {
const rootTag = createTag<number>({ name: "root" })

const app = compose()
.meta({ name: "bench" })
.step(bind(rootTag, literal(1)))

let previous: Spot<number> = rootTag.value

for (let layer = 0; layer < 20; layer++) {
const l = shape({ layer: literal(layer), previous }, ({ layer, previous }) => layer + previous)

const a = createTag<number>({ name: `${layer}:a` })
const b = createTag<number>({ name: `${layer}:b` })
const c = createTag<number>({ name: `${layer}:c` })
const d = createTag<number>({ name: `${layer}:d` })

const task = createTask({
name: `${layer}:task`,
run: {
context: [a.value, b.value, c.value, d.value],
fn: (list) => list.reduce((acc, x) => acc + x, 0),
},
})

app
.step([bind(a, shape(l, double)), bind(b, shape(l, double))])
.step([bind(c, shape(l, double)), bind(d, shape(l, double))])
.step(task)

previous = task.result
}

describe("compose.guard", () => {
bench("app", () => void app.guard(), { time: 1000 })
})

describe("compose.graph", () => {
bench("app", () => void app.graph(), { time: 1000 })
})
})

describe("single layer, wide sequence", () => {
const layer = compose()

for (let i = 0; i < 100; i++) {
const task = createTask({ name: `task:${i}`, run: { fn: vi.fn() } })

layer.step(task)
}

const app = compose().meta({ name: "bench" }).step(layer)

describe("compose.guard", () => {
bench("app", () => void app.guard(), { time: 1000 })
})

describe("compose.graph", () => {
bench("app", () => void app.graph(), { time: 1000 })
})
})

describe("multi layer, deep nesting", () => {
let current = compose().meta({ name: "root" })
let previous: Spot<number> = literal(1)

for (let layer = 0; layer < 100; layer++) {
const task = createTask({ name: `task:${layer}`, run: { fn: (v) => v + 1, context: previous } })

current = compose()
.meta({ name: `layer:${layer}` })
.step(current)
.step(task)

previous = task.result
}

const app = current

describe("compose.guard", () => {
bench("app", () => void app.guard(), { time: 1000 })
})

describe("compose.graph", () => {
bench("app", () => void app.graph(), { time: 1000 })
})
})
31 changes: 31 additions & 0 deletions packages/app-compose/src/compose/analyze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Context$, Dispatch$, type Binding, type Runnable, type RunnableInternal, type Task } from "@runnable"
import type { ComposableKind } from "./definition"
import { resolve, type Dependency } from "./resolver"

type RunnableMeta = { type: ComposableKind; display: { name: string }; writes: symbol[]; dependencies: Dependency }

type ComposeAnalyzer = {
get: (runnable: RunnableInternal) => RunnableMeta
}

const analyze = (runnable: RunnableInternal): RunnableMeta => {
const internal = runnable as RunnableInternal & (Task<unknown> | Binding)

const writes = Object.getOwnPropertySymbols(internal[Dispatch$])
const dependencies = resolve(internal[Context$])

return { type: internal.kind, display: { name: internal.name }, writes, dependencies }
}

const createAnalyzer = (): ComposeAnalyzer => {
const cache = new WeakMap<Runnable, RunnableMeta>()

const get = (runnable: Runnable): RunnableMeta => {
const analysis = cache.get(runnable) ?? analyze(runnable as RunnableInternal)
return (cache.set(runnable, analysis), analysis)
}

return { get }
}

export { createAnalyzer }
11 changes: 0 additions & 11 deletions packages/app-compose/src/compose/convert.ts

This file was deleted.

14 changes: 7 additions & 7 deletions packages/app-compose/src/compose/graph.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Context$, type RunnableInternal } from "@runnable"
import { toID } from "./convert"
import type { RunnableInternal } from "@runnable"
import { createAnalyzer } from "./analyze"
import type { ComposableKind, ComposeNode } from "./definition"
import { resolve } from "./resolver"

type EntryID = number

Expand All @@ -18,9 +17,10 @@ const graph = (root: ComposeNode): GraphNode => {
let entryID = 0
const symbolToID = new Map<symbol, EntryID>()

const analyzer = createAnalyzer()

const toEntry = (runnable: RunnableInternal): GraphNode => {
const deps = resolve(runnable[Context$])
const { type, display, writes } = toID(runnable)
const { type, display, writes, dependencies } = analyzer.get(runnable)

writes.forEach((x) => symbolToID.set(x, entryID))

Expand All @@ -29,8 +29,8 @@ const graph = (root: ComposeNode): GraphNode => {
meta: { name: display.name, kind: type },
id: entryID++,
dependencies: {
required: Array.from(deps.required, (x) => symbolToID.get(x) ?? -1),
optional: Array.from(deps.optional, (x) => symbolToID.get(x)).filter((x) => x !== undefined),
required: Array.from(dependencies.required, (x) => symbolToID.get(x) ?? -1),
optional: Array.from(dependencies.optional, (x) => symbolToID.get(x)).filter((x) => x !== undefined),
},
}
}
Expand Down
63 changes: 30 additions & 33 deletions packages/app-compose/src/compose/guard.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Context$, type RunnableInternal } from "@runnable"
import { difference, union } from "@shared"
import { toID } from "./convert"
import type { RunnableInternal } from "@runnable"
import { createAnalyzer } from "./analyze"
import type { ComposableKind, ComposeMeta, ComposeNode } from "./definition"
import { resolve } from "./resolver"

type NotifyEntry = { index?: number; node: ComposeNode }

Expand Down Expand Up @@ -49,6 +47,7 @@ type GuardConfig = { handler: GuardHandler }

const createGuard = ({ handler }: GuardConfig) => {
const notify = createNotify(handler)
const analyzer = createAnalyzer()

const duplicate = (root: ComposeNode) => {
const seen = new Set<symbol>()
Expand All @@ -60,7 +59,7 @@ const createGuard = ({ handler }: GuardConfig) => {
return current.children.forEach((node, index) => traverse([...stack, { node, index }]))

if (current.type === "run") {
const { type, display, writes } = toID(current.value)
const { type, display, writes } = analyzer.get(current.value as RunnableInternal)

if (writes.some((write) => seen.has(write))) notify.duplicate({ type, name: display.name, stack })

Expand All @@ -81,15 +80,12 @@ const createGuard = ({ handler }: GuardConfig) => {
return current.children.forEach((node, index) => traverse([...stack, { node, index }]))

if (current.type === "run") {
const internal = current.value as RunnableInternal

const { type, display, writes } = toID(current.value),
deps = resolve(internal[Context$])
const { type, display, writes, dependencies } = analyzer.get(current.value as RunnableInternal)

if (type === "binding") writes.forEach((id) => candidates.set(id, { type, name: display.name, stack }))

deps.required.forEach((id) => candidates.delete(id))
deps.optional.forEach((id) => candidates.delete(id))
dependencies.required.forEach((id) => candidates.delete(id))
dependencies.optional.forEach((id) => candidates.delete(id))
}
}

Expand All @@ -102,37 +98,38 @@ const createGuard = ({ handler }: GuardConfig) => {
const traverse = (stack: NotifyEntry[], available: Set<symbol>): Set<symbol> => {
const { node: current } = stack.at(-1)!

if (current.type === "run") {
const internal = current.value as RunnableInternal
const { type, display, writes } = toID(current.value)

const deps = resolve(internal[Context$])
switch (current.type) {
case "run":
const { type, display, writes, dependencies } = analyzer.get(current.value as RunnableInternal)

const missing = difference(deps.required, available)
if (missing.size > 0) notify.notSatisfied({ type, name: display.name, stack, missing })
const missing = new Set<symbol>()
for (const id of dependencies.required) if (!available.has(id)) missing.add(id)

return new Set(writes)
}
if (missing.size > 0) notify.notSatisfied({ type, name: display.name, stack, missing })

// all concurrent steps are isolated
if (current.type === "con")
return current.children.map((node, index) => traverse([...stack, { node, index }], available)).reduce(union)
return new Set(writes)

// sequential steps provide their writes to the next steps
if (current.type === "seq") {
let wrote = new Set<symbol>()
// all concurrent steps are isolated
case "con": {
const wrote = new Set<symbol>()

for (const [index, node] of current.children.entries()) {
const local = union(wrote, available)
const added = traverse([...stack, { node, index }], local)
for (const [index, node] of current.children.entries())
traverse([...stack, { node, index }], available).forEach((item) => wrote.add(item))

wrote = union(wrote, added)
return wrote
}

return wrote
}
// sequential steps share the context of previous steps
case "seq": {
const wrote = new Set<symbol>(),
local = new Set<symbol>(available)

for (const [index, node] of current.children.entries())
traverse([...stack, { node, index }], local).forEach((item) => (wrote.add(item), local.add(item)))

throw new Error(/* unreachable */)
return wrote
}
}
}

traverse([{ node: root }], new Set())
Expand Down
2 changes: 1 addition & 1 deletion packages/app-compose/src/compose/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ function resolve(spot: SpotInternal): Dependency {
return { required, optional }
}

export { resolve }
export { resolve, type Dependency }
14 changes: 1 addition & 13 deletions packages/app-compose/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,5 @@ const isObject = (x: unknown): x is object => Object.prototype.toString.call(x)
const identity = <T>(x: T): T => x
const T = (): true => true

const difference = <T>(left: Set<T>, right: Set<T>): Set<T> => {
const out = new Set<T>(left)
for (const item of right) out.delete(item)
return out
}

const union = <T>(left: Set<T>, right: Set<T>): Set<T> => {
const out = new Set<T>(left)
for (const item of right) out.add(item)
return out
}

export { LIBRARY_NAME, T, difference, identity, isObject, union }
export { LIBRARY_NAME, T, identity, isObject }
export type { AnyShape, BuiltInObject, Eventual }