Skip to content

feat(runtime): schedule object fields concurrently#44

Open
vito wants to merge 4 commits into
mainfrom
concurrency
Open

feat(runtime): schedule object fields concurrently#44
vito wants to merge 4 commits into
mainfrom
concurrency

Conversation

@vito

@vito vito commented Apr 27, 2026

Copy link
Copy Markdown
Owner

I kicked around a few ideas for concurrency, and I think this one strikes the sweet spot for Dang's focus.

We really just need a way to schedule work in parallel and group the results and/or fail-fast when any of them fail. In Go you might model this with goroutines, wait groups, context cancellation, and storing results in a map using locks to synchronize writes (or sync.Map but you get the point). But that's all low-level plumbing: tons of boilerplate to write, and tons of independent language features that would dramatically increase Dang's scope.

This PR instead enhances a single pre-existing feature: object literals - quick reminder example of those:

dang> {{foo: {{bar: 1}}, fizz: foo.bar}}
=> module {fizz: Int!, foo: {bar: Int!}!}
dang> toJSON({{foo: {{bar: 1}}, fizz: foo.bar}})
=> {"fizz":1,"foo":{"bar":1}}

Currently object literals instantiate a new scope and evaluate + assigns slots within it, serially.

With this PR, they are instead evaluated in DAG order. Independent slots are evaluted in parallel, and inter-dependent slots are evaluated in dependency order.

This seems pretty elegant to me, because it means {{}} literals that make API queries are now just as parallel as GraphQL multi-field selection syntax - i.e. {{bar: foo.bar, baz: foo.baz}} is just as parallel as foo.{{bar, baz}} (except it sends two requests instead of one).

Take this snippet, from one of Dang's tests:

pub complex_query = {{
  server: serverInfo.{{ version, platform }},
  users: users.{{ name, emails }},
  posts: posts.{{ title, author.{{ name }} }},
  titles: postTitles
}}

Previously this would send 3 queries one after another. Now it sends them all at once.

With the DAG evaluation order, this also means you can hypothetically define entire pipelines as single objects, and let Dang figure out how to evaluate everything optimally:

{{
  images: {{
    linux: build("linux")
    windows: build("windows")
    darwin: build("darwin")
  }}
  test: test
  integration: integration(images.linux)
}}
# evaluates:
# 1. build("linux"), build("windows"), build("darwin"), test
# 2. integration(images.linux)

Failing fast (or not)

Evaluation fails as soon as anything fails, i.e. it fails fast. I think this is the best default, since the alternative would mean returning a mixed report of successful results and failures, and seems like it could be represented by composing with something like try to have fields typed as something like Either foo Error.

@fire-ant

Copy link
Copy Markdown

I've been hacking on CI with Dagger/Dang for its pure speed and neatness. This would be great to see in the next few dagger releases as i work on a monorepo and need to rebuild multiple deps from time to time :)

@vito

vito commented Jun 15, 2026

Copy link
Copy Markdown
Owner Author

@fire-ant Good to know! I've just been sitting on this because no one actually asked for it yet, and I didn't want to theorycraft too hard. :)

Do you happen to have a code snippet demonstrating how you'd use it, or are you mostly guessing that it would help in some way?

@fire-ant

fire-ant commented Jun 15, 2026

Copy link
Copy Markdown

today i do this for some python build pipeline:

pub all(Token: Secret, cachebust: String! = "repeat"): String! {
  lint                                                   # ruff check    (container)
  fmt                                                    # ruff format   (container)
  let testOut = test(Token: Token, cachebust: cachebust)  # pytest (container)
  let pkgOut  = package                                  # uv build      (container)
  comp.name + " all stages:\n  lint: ok\n  fmt: ok\n  test: " + testOut + "\n  package: " + pkgOut
}

where I think the above would allow:

pub all(Token: Secret, cachebust: String! = "repeat"): String! {
  let r = {{
    lint:    lint,
    fmt:     fmt,
    test:    test(Token: Token, cachebust: cachebust),
    package: package,
  }}
  comp.name + " all stages:\n  lint: ok\n  fmt: ok\n  test: " + r.test + "\n  package: " + r.package
}

Which would be a better expression, especially when I have monorepo of all the things where I'm effectively replacing githubs path filtering with a thin dang orchestration module detecting where paths have diverged and running many builds in parallel. I previously did this in go-sdk with goroutine's and that worked great, but everything is so fast with dang Im trying to move all my modules to dang. A lot of our fan-out is currently list.map { … } where I'd still need to do somethings for maps => object literals, but i think there is a fix even closer to being merged which will resolve this for me

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying dang with  Cloudflare Pages  Cloudflare Pages

Latest commit: b891c17
Status: ✅  Deploy successful!
Preview URL: https://fa8b3bbc.dang-3kk.pages.dev
Branch Preview URL: https://concurrency.dang-3kk.pages.dev

View logs

vito added 4 commits June 16, 2026 16:53
Object scopes hold lazily-evaluated bindings in a Pending map that force()
realizes on first lookup, moving the entry into Values. That was safe while
evaluation was single-threaded, but evaluating object fields concurrently
lets several goroutines read a scope while another forces a binding in it,
racing on the maps.

Guard each Object's maps with a mutex, and serialize forcing per binding so
a concurrent reader waits for the in-flight initializer and shares its
result instead of re-running it. Cycle detection moves from a flag on the
pending entry to a marker threaded through the context: a genuine
self-referential cycle still errors, while ordinary concurrent access to the
same binding waits rather than being misreported as a cycle.

Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Object literal fields were evaluated serially. Evaluate independent fields
in parallel and dependent fields in dependency order: build a dependency
graph over the fields, walk it in layers where each layer's fields run
concurrently, and wait for a layer before starting the next. Cyclic field
dependencies are rejected during inference so the error points at the
literal, and each layer's results are published in source order so the
object layout stays deterministic. Evaluation fails fast on the first error.

Extract the dependency-graph machinery shared with module-variable ordering
into slotDepGraph, and split FieldDecl evaluation into EvalValue (compute)
and Publish (bind) so a field's value can be produced in a forked scope and
installed once its layer completes.

Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Replace the explicit layered scheduler for object literals with the scope's
own lazy-binding machinery. Each field is installed as a lazy initializer in
the new object and all are forced concurrently: forcing a field that
references a sibling forces that sibling first and shares the single result,
so dependency order falls out of laziness while independent fields run in
parallel. This drops the per-literal layering, the hand-rolled
goroutine/channel orchestration, and the Kahn layering helper; the
dependency graph stays only to order inference and reject cycles, which also
guarantees forcing cannot deadlock.

A field's own name resolves to the enclosing scope rather than the field
being defined, as before, so `users: users.{{...}}` reads the outer `users`
instead of recursing into itself.

force() now memoizes a failed initializer's error rather than leaving it
retryable, so a failed dependency is reported once instead of being re-run
by each dependent. The first failure cancels in-flight siblings, but every
field is awaited and the lowest-source-index error is returned, keeping the
reported failure deterministic.

Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Record literal fields are evaluated concurrently with dependency order
resolved lazily, so fields may reference each other in any order and a
record of GraphQL selections issues its queries in parallel. Note these
semantics on the syntax and GraphQL-interop pages and in the language skill,
including that a field's own name resolves to the enclosing scope rather
than the field being defined, and that a cyclic field reference is a compile
error.

Also record the implementation invariants in the internals skill: fields are
forced as parallel lazy bindings, Object is mutex-guarded for concurrent
forcing, and cycles are caught statically during inference for object fields
or dynamically via the force chain for module cycles routed through
functions.

Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants