Concurrency #

Fibers, structured concurrency with limits, racing and timeouts, interruption, and the coordination primitives Deferred, Queue, PubSub, and Semaphore.

The problem. Concurrency in plain TypeScript is 3 separate tools that do not know about each other. You want to fetch 6 users, at most 2 at a time, with a limit of 1 second. You also want to cancel the rest when 1 fetch fails. You write code like this:

const controller = new AbortController()
const limit = pLimit(2)                       // a hand-rolled or npm queue
const timer = setTimeout(() => controller.abort(), 1000)
try {
  const users = await Promise.all(
    ids.map((id) => limit(() => fetchUser(id, controller.signal)))
  )
} finally {
  clearTimeout(timer)
}

Look at what this code does not do. Promise.all rejects on the first failure, but the other 5 requests continue to run. Nobody cancels them. The AbortController only works if every function down the stack passes signal along. pLimit is a separate object, and it does not know that a timeout happened. If a cancelled request was in the middle of a file write, no cleanup runs. Each piece is correct alone. Together they lose work, lose errors, and lose resources.

The shift

Today you think of concurrent work as Promises that you start and then collect. A Promise has no owner. After you create it, it runs until it settles, whatever happens around it. Effect asks you to think in fibers with owners. A fiber is a lightweight thread of execution. The Effect runtime schedules the fibers on the single JavaScript thread.

A fiber always starts from another fiber, its parent. By default, a fiber lives no longer than its parent. When the parent ends, the runtime interrupts its children. When 1 of several concurrent tasks fails, the runtime interrupts its siblings. When a race has a winner, the runtime interrupts the other fibers. An interrupt runs the finalizers, so a cancelled task runs its own cleanup.

The name for this is structured concurrency: the tree of active work matches the tree of your code. The result is that concurrency becomes an option that you pass, not a library that you add. Effect.all(tasks, { concurrency: 2 }) limits the work, collects the results in order, stops at the first failure, and cancels the rest. It does this in 1 line, and the compiler still knows the error type.

You need to... In plain TS In Effect
Run many, collect all Promise.all Effect.all / Effect.forEach with { concurrency }
Limit how many run at once p-limit the concurrency option, or a Semaphore
Use the first result Promise.race Effect.race / Effect.raceAll, the runtime interrupts the other fibers
Stop after a time limit setTimeout + AbortController Effect.timeout / Effect.timeoutOption
Cancel work AbortSignal passed by hand Fiber.interrupt, automatic for children
Give a value to a waiter once a captured resolve Deferred
Producer/consumer buffer array + a manual poll loop Queue with backpressure
Broadcast to many listeners EventEmitter PubSub

In this section you will:

  1. Fork fibers and watch them interleave.
  2. Run work with limits.
  3. Race effects and set timeouts.
  4. Interrupt fibers safely.
  5. Coordinate fibers with the 4 primitives above.

Learn #

Lesson 1. Fibers: fork, interleave, join #

A fiber is a lightweight thread that the Effect runtime schedules. There is still 1 JavaScript thread, so fibers do not run at the same instant. They take turns. A fiber runs until it yields (at a sleep, an async boundary, or an explicit Effect.yieldNow). Then another fiber gets a turn. The name for this is cooperative scheduling.

Effect.forkChild(effect) starts effect in a new fiber and returns a Fiber handle immediately. The new fiber does not run yet. It waits until the current fiber yields. Fiber.join(fiber) suspends until the fiber finishes. Then it gives you the success value, or it fails with the error of the fiber.

In the program below, 2 workers each print 3 steps and yield after each step. The parent forks both workers, prints a line, and then joins them. Watch how the lines interleave 1 step at a time.

import { Effect, Fiber } from "effect"

const worker = (name: string) =>
  Effect.gen(function* () {
    for (let i = 1; i <= 3; i++) {
      console.log(name, "step", i)
      yield* Effect.yieldNow   // give the other fibers a turn
    }
    return name + " done"
  })

const program = Effect.gen(function* () {
  // forkChild starts the work in a new fiber and returns immediately
  const a = yield* Effect.forkChild(worker("A"))
  const b = yield* Effect.forkChild(worker("B"))
  console.log("parent: both forked, neither has run yet")

  // join waits for the fiber and gives back its success value
  const resultA = yield* Fiber.join(a)
  const resultB = yield* Fiber.join(b)
  console.log(resultA, "/", resultB)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

The line of the parent prints before any worker step, although both forks came first. A fork only schedules the work. Try to remove yield* Effect.yieldNow. Each worker then runs all 3 steps in 1 turn, so you get A 1, 2, 3 and then B 1, 2, 3.

Lesson 2. Fiber.await gives an Exit, Fiber.interrupt stops a fiber #

Fiber.join is convenient, but it raises the failure of the fiber again in your own fiber. When you want to inspect the result instead, use Fiber.await. It never fails. It gives you the Exit of the fiber: a Success with a value, or a Failure with a Cause.

Function Waits? On fiber failure
Fiber.join(f) Yes Fails the caller with the same error
Fiber.await(f) Yes Succeeds with Exit.Failure
Fiber.interrupt(f) Yes, until the fiber has stopped Returns void

Fiber.interrupt asks a fiber to stop and waits until it has stopped. The finalizers of the fiber run, which includes any Effect.onInterrupt handler. Afterwards, the Exit of the fiber is a Failure. Its Cause has a reason with the tag "Interrupt", not "Fail". An interrupt is a third kind of outcome, separate from success and failure.

import { Cause, Effect, Exit, Fiber } from "effect"

const slowJob = Effect.gen(function* () {
  yield* Effect.sleep("50 millis")
  return "report ready"
}).pipe(Effect.onInterrupt(() => Effect.sync(() => console.log("slowJob: cleaning up"))))

const program = Effect.gen(function* () {
  const ok = yield* Effect.forkChild(Effect.succeed(42))
  const bad = yield* Effect.forkChild(Effect.fail("disk full"))
  const slow = yield* Effect.forkChild(slowJob)

  // await never throws: it hands you the Exit to inspect
  const exit1 = yield* Fiber.await(ok)
  const exit2 = yield* Fiber.await(bad)
  console.log(exit1._tag, Exit.isSuccess(exit1) ? exit1.value : "")
  console.log(exit2._tag, Exit.isFailure(exit2) ? Cause.squash(exit2.cause) : "")

  // interrupt asks the fiber to stop and waits until it has stopped
  yield* Effect.sleep("5 millis")
  yield* Fiber.interrupt(slow)
  const exit3 = yield* Fiber.await(slow)
  const reasons = Exit.isFailure(exit3) ? exit3.cause.reasons.map((r) => r._tag).join(",") : ""
  console.log(exit3._tag, "reason:", reasons)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

The cleanup line appears before Fiber.interrupt returns, because interrupt waits for the finalizers. Try to replace Fiber.await(bad) with Fiber.join(bad). The whole program then fails with "disk full", because join passes the failure on.

Lesson 3. Who owns a fiber: forkChild, forkScoped, forkDetach #

Every fiber has an owner, and the owner decides when the fiber stops. This is the core of structured concurrency. 3 fork functions give 3 lifetimes:

Function The fiber stops when... Use when
Effect.forkChild its parent fiber finishes background work that is only useful while the parent runs
Effect.forkScoped the enclosing Scope closes work tied to a resource, such as a heartbeat while a connection is open
Effect.forkDetach it finishes on its own work that must continue after the caller. You own the cleanup

The case where the parent finishes first surprises people. If you forkChild a task and the parent returns before the task is done, the runtime interrupts the task. In plain TypeScript, the Promise continues to run with nobody to watch it. In Effect, "nobody watches it" is not permitted unless you say so with forkDetach.

The program below runs the same ticker under each lifetime. Only the detached ticker finishes.

import { Effect, Fiber } from "effect"

const ticker = (name: string) =>
  Effect.gen(function* () {
    console.log(name, "started")
    yield* Effect.sleep("30 millis")
    console.log(name, "finished")
  }).pipe(Effect.onInterrupt(() => Effect.sync(() => console.log(name, "interrupted"))))

// 1. A child dies with its parent. The parent returns after 5ms, long before 30ms.
const parent = Effect.gen(function* () {
  yield* Effect.forkChild(ticker("child"))
  yield* Effect.sleep("5 millis")
  console.log("parent finished first")
})

const program = Effect.gen(function* () {
  const parentFiber = yield* Effect.forkChild(parent)
  yield* Fiber.join(parentFiber)

  // 2. A scoped fiber dies when the scope closes
  yield* Effect.scoped(Effect.gen(function* () {
    yield* Effect.forkScoped(ticker("scoped"))
    yield* Effect.sleep("5 millis")
    console.log("leaving scope")
  }))

  // 3. A detached fiber has no owner: we must join it ourselves or it leaks
  const detached = yield* Effect.forkDetach(ticker("detached"))
  yield* Effect.sleep("5 millis")
  console.log("parent moving on, detached still runs")
  yield* Fiber.join(detached)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

Try to delete the final Fiber.join(detached). The program ends before "detached finished" can print, and the fiber is abandoned. Note: forkDetach gives you freedom, and at the same time it removes the safety of an owner.

Lesson 4. Effect.all and forEach with a concurrency limit #

Most concurrency does not need a manual fork. Effect.all and Effect.forEach run a collection of effects and collect the results in input order, in whatever order the work finished. In plain TypeScript you combine Promise.all with a limiter:

const limit = pLimit(2)
const users = await Promise.all(ids.map((id) => limit(() => fetchUser(id))))

In Effect the limit is an option:

Option Meaning
no option 1 at a time, in order
{ concurrency: 2 } at most 2 active at once
{ concurrency: "unbounded" } everything at once
{ discard: true } do not collect the results, return void

The program proves the limit. It does not trust the option. A Ref counts the number of active fake requests. Another Ref records the highest count seen. Each configuration runs the same 6 requests.

import { Effect, Ref } from "effect"

const program = Effect.gen(function* () {
  const inFlight = yield* Ref.make(0)
  const maxInFlight = yield* Ref.make(0)

  // A fake request that records how many copies of itself run at once
  const fetchUser = (id: number) =>
    Effect.gen(function* () {
      const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1)
      yield* Ref.update(maxInFlight, (m) => Math.max(m, now))
      yield* Effect.sleep("5 millis")
      yield* Ref.update(inFlight, (n) => n - 1)
      return "user-" + id
    })

  const ids = [1, 2, 3, 4, 5, 6]

  const sequential = yield* Effect.forEach(ids, fetchUser)                     // one at a time
  console.log("sequential", sequential.join(","), "max:", yield* Ref.get(maxInFlight))

  yield* Ref.set(maxInFlight, 0)
  const limited = yield* Effect.forEach(ids, fetchUser, { concurrency: 2 })      // at most two
  console.log("limited   ", limited.join(","), "max:", yield* Ref.get(maxInFlight))

  yield* Ref.set(maxInFlight, 0)
  const all = yield* Effect.all(ids.map(fetchUser), { concurrency: "unbounded" }) // everything
  console.log("unbounded ", all.join(","), "max:", yield* Ref.get(maxInFlight))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

The results are in the same order in all 3 runs. Effect keeps the position of each input, also when a later request finishes first. Try { concurrency: 3 }. The maximum becomes 3, and the order stays the same.

Lesson 5. Racing and timeouts: the loser is interrupted #

Effect.race(a, b) runs both effects and returns the first success. Effect.raceAll([...]) does the same for a list. As soon as a winner is known, the runtime interrupts the other fibers, and their finalizers run. A fiber that did not finish first is called a loser. In plain TypeScript, Promise.race returns the first settled value, but the losers continue to run to the end.

A timeout is a race against a clock. There are 3 variants:

Function On timeout Type of result
Effect.timeout(eff, "5 millis") fails with TimeoutError Effect<A, E | TimeoutError>
Effect.timeoutOption(eff, "5 millis") succeeds with Option.none() Effect<Option<A>, E>
Effect.timeoutOrElse(eff, { duration, orElse }) runs the fallback Effect<A | B, E | E2>

In every case, the runtime interrupts the slow effect when the time runs out. The program below attaches an onInterrupt finalizer to each fake request, so you can see the cancelled losers. For raceAll, the program collects the losers in a Ref and prints them in sorted order. 2 fibers that the runtime interrupts at the same moment have no fixed order.

import { Effect, Option, Ref } from "effect"

const program = Effect.gen(function* () {
  const cancelled = yield* Ref.make<Array<string>>([])

  // A fake request that records when it is cancelled
  const request = (name: string, ms: number) =>
    Effect.sleep(ms).pipe(                   // a plain number means milliseconds
      Effect.as(name),
      Effect.onInterrupt(() => Ref.update(cancelled, (list) => [...list, name]))
    )

  // race: first success wins, the other one is interrupted
  const winner = yield* Effect.race(request("mirror-eu", 50), request("mirror-us", 5))
  console.log("winner:", winner, "| cancelled:", yield* Ref.get(cancelled))

  // raceAll: same for a list
  yield* Ref.set(cancelled, [])
  const fastest = yield* Effect.raceAll([request("a", 40), request("b", 5), request("c", 60)])
  console.log("fastest:", fastest, "| cancelled:", (yield* Ref.get(cancelled)).sort())

  // timeoutOption: None on timeout, and the slow work is interrupted
  yield* Ref.set(cancelled, [])
  const maybe = yield* Effect.timeoutOption(request("report", 50), "5 millis")
  console.log("timed out:", Option.isNone(maybe), "| cancelled:", yield* Ref.get(cancelled))

  // timeout: a typed TimeoutError that you can catch by its tag
  const value = yield* Effect.timeout(request("report", 50), "5 millis").pipe(
    Effect.catchTag("TimeoutError", () => Effect.succeed("fallback"))
  )
  console.log("value:", value)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

The cancelled list is already full when the next line prints. The race does not return until the losers have finished their cleanup. Try Effect.race(Effect.fail("boom"), request("mirror-us", 5)). A fast failure does not win. race waits for the success. Effect.raceFirst is the variant where any completion, also a failure, ends the race.

Lesson 6. Interruption: cooperative, cleanup-safe, and not an error #

An interrupt is how Effect cancels work. 3 facts make it safe to depend on:

  1. It is cooperative. The runtime interrupts a fiber only at a yield point. Synchronous code between yields always completes.
  2. Finalizers run. Effect.onInterrupt, Effect.ensuring, and acquireRelease finalizers all run before the interrupted fiber counts as done.
  3. It is a separate outcome. The Cause has a reason with the tag "Interrupt". Cause.hasFails is false, so error handlers such as catch do not see it as an error.

Sometimes a block must never stop in the middle, for example a header write followed by a body write. Wrap the block in Effect.uninterruptible. An interrupt request that arrives during that block waits until the block finishes. Then it takes effect at the next yield point.

The last part shows the structured-concurrency rule in Effect.all. When 1 sibling fails, the runtime interrupts the other siblings, and the overall result is the failure.

import { Cause, Effect, Exit, Fiber } from "effect"

const saveFile = Effect.gen(function* () {
  // This block runs fully or not at all: interruption waits for it
  yield* Effect.uninterruptible(Effect.gen(function* () {
    console.log("write header")
    yield* Effect.sleep("10 millis")
    console.log("write body")
  }))
  yield* Effect.sleep("50 millis")     // interruptible again: this is where we get stopped
  console.log("never printed")
}).pipe(Effect.onInterrupt(() => Effect.sync(() => console.log("saveFile: closing handle"))))

const program = Effect.gen(function* () {
  const fiber = yield* Effect.forkChild(saveFile)
  yield* Effect.sleep("2 millis")
  yield* Fiber.interrupt(fiber)         // arrives during the uninterruptible block
  const exit = yield* Fiber.await(fiber)
  console.log("interrupted:", Exit.hasInterrupts(exit))

  // Interrupting yourself: the Cause says Interrupt, not Fail
  const self = yield* Effect.exit(Effect.gen(function* () {
    yield* Effect.interrupt
    return "unreachable"
  }))
  if (Exit.isFailure(self)) {
    const tags = self.cause.reasons.map((r) => r._tag).join(",")
    console.log("reasons:", tags, "| hasFails:", Cause.hasFails(self.cause))
  }

  // In Effect.all one failure interrupts the siblings
  const exit2 = yield* Effect.exit(Effect.all([
    Effect.sleep("50 millis").pipe(Effect.onInterrupt(() => Effect.sync(() => console.log("sibling cancelled")))),
    Effect.sleep("5 millis").pipe(Effect.andThen(Effect.fail("bad input")))
  ], { concurrency: "unbounded" }))
  console.log(exit2._tag, Exit.isFailure(exit2) ? Cause.squash(exit2.cause) : "")
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

"write body" printed although the interrupt arrived at 2 ms, during the 10 ms sleep inside the uninterruptible block. Remove Effect.uninterruptible, and only "write header" prints. Caution: an uninterruptible infinite loop can never stop.

Lesson 7. Coordinating fibers: Deferred and Queue #

A fork is easy. Communication between fibers is where plain TypeScript becomes difficult. You capture a resolve function in a closure, or you push into an array and poll it. Effect gives you typed primitives instead.

A Deferred<A, E> is a cell that you complete 1 time. Any number of fibers can Deferred.await it and suspend. Exactly 1 Deferred.succeed (or fail) wakes all of them with the same value. The cell ignores a second completion and returns false.

A Queue<A> carries many values from producers to consumers. Queue.bounded(n) has a capacity. When the queue is full, Queue.offer suspends the producer until a consumer takes a value. This is backpressure, and it prevents that a fast producer fills the memory. Queue.end signals that no more values come. A consumer that takes from an ended, empty queue fails with Done, and it can stop.

In the program, watch the producer print "queue full" before each offer that must wait.

import { Cause, Deferred, Effect, Fiber, Queue } from "effect"

const program = Effect.gen(function* () {
  // Deferred: a one-shot signal. The server waits, the parent completes it.
  const configReady = yield* Deferred.make<string>()
  const server = yield* Effect.forkChild(Effect.gen(function* () {
    console.log("server: waiting for config")
    const config = yield* Deferred.await(configReady)   // suspends here
    console.log("server: started with", config)
  }))
  yield* Effect.sleep("5 millis")
  yield* Deferred.succeed(configReady, "port=8080")
  yield* Fiber.join(server)

  // Queue: capacity 2, so the producer is slowed down to the consumer's pace
  const jobs = yield* Queue.bounded<number, Cause.Done>(2)

  const producer = yield* Effect.forkChild(Effect.gen(function* () {
    for (const n of [1, 2, 3, 4, 5]) {
      if (yield* Queue.isFull(jobs)) console.log("producer: queue full, waiting")
      yield* Queue.offer(jobs, n)                        // suspends while full
      console.log("producer: offered", n)
    }
    yield* Queue.end(jobs)                               // no more jobs
  }))

  const consumer = yield* Effect.forkChild(Effect.gen(function* () {
    const seen: Array<number> = []
    yield* Effect.gen(function* () {
      seen.push(yield* Queue.take(jobs))
      yield* Effect.sleep("5 millis")                    // a slow consumer
    }).pipe(Effect.forever, Effect.catchTag("Done", () => Effect.void))
    return seen
  }))

  yield* Fiber.join(producer)
  console.log("consumer got", (yield* Fiber.join(consumer)).join(","))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

Change Queue.bounded<number, Cause.Done>(2) to Queue.unbounded<number, Cause.Done>(). Keep the Cause.Done type argument, because Queue.end needs it. The producer never waits and offers all 5 values immediately. A bounded queue costs a little producer speed and gives a guarantee about memory.

Lesson 8. Queue in depth: bounded, dropping, sliding, and end #

A Queue has a capacity and a strategy. The strategy decides what happens when a producer offers a value to a full queue. Lesson 7 used the default strategy. There are 4 constructors.

Constructor When the queue is full, offer... Returns Use it when
Queue.bounded(n) waits until a consumer takes a value true every value must arrive, and a slow consumer must slow the producer
Queue.dropping(n) refuses the new value false the old values are more important, for example the first N requests
Queue.sliding(n) removes the oldest value and stores the new one true only the latest values matter, for example sensor values
Queue.unbounded() is never full true the producer is slower than the consumer, and memory is not a concern

Queue.offer returns a boolean in each case. For bounded and unbounded, the value is always true. For dropping, false tells the producer that the queue did not store the value.

Queue.end closes the queue for new values. The values that are already in the queue stay. Consumers take them 1 by 1 or in groups. Queue.takeAll takes every value that is present. Queue.takeBetween(queue, min, max) waits for at least min values and takes at most max. When the queue is empty and ended, a take fails with Cause.Done. The type argument Cause.Done on the constructor makes this failure part of the type.

The program tests each strategy. In part 1, the log proves that the second offer waited for the take.

import { Cause, Effect, Fiber, Queue, Ref } from "effect"

const program = Effect.gen(function* () {
  // 1. bounded: offer waits while the queue is full. The log proves the order.
  const log = yield* Ref.make<Array<string>>([])
  const note = (line: string) => Ref.update(log, (xs) => [...xs, line])
  const bounded = yield* Queue.bounded<number>(1)
  const producer = yield* Effect.forkChild(Effect.gen(function* () {
    yield* Queue.offer(bounded, 1)
    yield* note("offered 1")
    yield* Queue.offer(bounded, 2)          // suspends: the only slot is taken
    yield* note("offered 2")
  }))
  yield* Effect.sleep("5 millis")
  yield* note("consumer takes")
  yield* Queue.take(bounded)                // frees the slot, the producer continues
  yield* Fiber.join(producer)
  console.log("bounded :", (yield* Ref.get(log)).join(", "))

  // 2. dropping: a full queue refuses the new value, and offer returns false
  const dropping = yield* Queue.dropping<number>(2)
  const accepted = yield* Effect.forEach([1, 2, 3], (n) => Queue.offer(dropping, n))
  console.log("dropping: accepted", accepted.join(","), "| kept", (yield* Queue.takeAll(dropping)).join(","))

  // 3. sliding: a full queue removes the oldest value, and offer returns true
  const sliding = yield* Queue.sliding<number>(2)
  const accepted2 = yield* Effect.forEach([1, 2, 3], (n) => Queue.offer(sliding, n))
  console.log("sliding : accepted", accepted2.join(","), "| kept", (yield* Queue.takeAll(sliding)).join(","))

  // 4. end: the queue keeps its values, consumers drain them, then take fails with Done
  const jobs = yield* Queue.unbounded<number, Cause.Done>()
  yield* Queue.offerAll(jobs, [1, 2, 3, 4, 5])
  yield* Queue.end(jobs)
  console.log("size after end:", yield* Queue.size(jobs))
  console.log("batch of 1..3 :", (yield* Queue.takeBetween(jobs, 1, 3)).join(","))
  console.log("the rest      :", (yield* Queue.takeAll(jobs)).join(","))
  const last = yield* Queue.take(jobs).pipe(
    Effect.catchTag("Done", () => Effect.succeed("Done: no more jobs"))
  )
  console.log("then          :", last)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

In part 1, "offered 2" comes after "consumer takes", although the producer called offer first. The producer waited 5 ms for the consumer. Change Queue.bounded<number>(1) to Queue.dropping<number>(1): the second offer returns false at once, and "offered 2" moves before "consumer takes". Note: Queue.end did not remove the 5 values. The end signal only arrives after the last value.

Lesson 9. Latch: a gate that opens for all waiters #

A Latch is a gate with 2 states: open or closed. Latch.make() creates a closed latch. Latch.await(latch) suspends a fiber while the latch is closed. Latch.open(latch) opens the gate and wakes every waiter, in the order of arrival. A fiber that arrives later does not wait, because the gate stays open. Latch.close(latch) closes the gate again for new waiters. This makes a latch reusable.

Latch.whenOpen(latch, effect) is the common form. It waits for the gate, then it runs the effect. Use it for "do not serve requests until the cache is warm", or "pause all workers, then resume them".

Latch.release is a related function. It wakes the current waiters, but it does not open the gate. A fiber that arrives afterwards suspends again.

Primitive Carries Completes Reusable Use it when
Deferred 1 value or 1 error 1 time. A second succeed returns false No a waiter needs a result from another fiber
Latch no value open, and close resets it Yes many fibers wait for a signal: start, pause, resume
Semaphore permits never. A permit comes back after use Yes at most N fibers use a resource at the same time

The program forks 3 workers before the latch opens. All 3 run after 1 open. Then the program closes the latch and shows that a new waiter suspends. The last line shows the 1-time rule of Deferred.

import { Deferred, Effect, Fiber, Latch, Option, Ref } from "effect"

const program = Effect.gen(function* () {
  const gate = yield* Latch.make()             // starts closed
  const log = yield* Ref.make<Array<string>>([])
  const note = (line: string) => Ref.update(log, (xs) => [...xs, line])

  const worker = (name: string) =>
    Effect.gen(function* () {
      yield* note(name + " waits")
      yield* Latch.await(gate)                 // suspends while the gate is closed
      yield* note(name + " runs")
    })

  const fibers = yield* Effect.forEach(["a", "b", "c"], (n) => Effect.forkChild(worker(n)))
  yield* Effect.sleep("5 millis")
  console.log("open:", Latch.isOpen(gate), "|", (yield* Ref.get(log)).join(", "))

  yield* Latch.open(gate)                      // wakes every waiter at once
  yield* Fiber.joinAll(fibers)
  console.log("open:", Latch.isOpen(gate), "|", (yield* Ref.get(log)).join(", "))

  // whenOpen on an open latch runs the effect at once
  console.log(yield* Latch.whenOpen(gate, Effect.succeed("late worker runs at once")))

  // close makes the gate reusable: a new waiter suspends again
  yield* Latch.close(gate)
  const late = yield* Latch.await(gate).pipe(Effect.as("passed"), Effect.timeoutOption("5 millis"))
  console.log("after close:", Option.isNone(late) ? "a new waiter suspends" : "passed")

  // Deferred: 1 value, 1 time. The second completion is ignored.
  const cell = yield* Deferred.make<number>()
  const first = yield* Deferred.succeed(cell, 1)
  const second = yield* Deferred.succeed(cell, 2)
  console.log("deferred:", first, second, "value", yield* Deferred.await(cell))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

The 3 workers wait on 1 gate, and 1 open call wakes all of them. A Deferred can do the same with Deferred.await, but it cannot close again. Change Latch.make() to Latch.make(true): the latch starts open, so each worker runs in its first turn, and the log becomes "a waits, a runs, b waits, b runs, c waits, c runs".

Lesson 10. PubSub and Semaphore, and which primitive to use #

2 more primitives complete the set.

A PubSub<A> broadcasts. Every subscriber receives every message that the publisher sends after the subscription. In a Queue, each value goes to exactly 1 taker. PubSub.subscribe needs a Scope, so the runtime releases the subscription when the scope closes. Wrap the code in Effect.scoped.

A Semaphore holds a fixed number of permits. Semaphore.withPermits(sem, 1)(effect) waits for a permit, runs the effect, and gives the permit back. It gives the permit back also on failure or interrupt. Use it for "at most N of this specific resource at once", such as a database with a small connection pool. The structure of the callers does not matter.

Primitive Use it when
Deferred 1 fiber must wait for a single value or signal from another fiber
Queue work items flow from producers to consumers, and each item is processed 1 time
PubSub several listeners must all see every event
Semaphore a shared resource permits only N users at a time, across unrelated callers
concurrency option you only need to limit 1 forEach/all call
import { Effect, PubSub, Ref, Semaphore } from "effect"

const program = Effect.gen(function* () {
  // PubSub: every subscriber sees every message
  yield* Effect.scoped(Effect.gen(function* () {
    const events = yield* PubSub.unbounded<string>()
    const audit = yield* PubSub.subscribe(events)      // released when the scope closes
    const mailer = yield* PubSub.subscribe(events)
    yield* PubSub.publish(events, "order:created")
    yield* PubSub.publish(events, "order:paid")
    console.log("audit  saw", yield* PubSub.take(audit), yield* PubSub.take(audit))
    console.log("mailer saw", yield* PubSub.take(mailer), yield* PubSub.take(mailer))
  }))

  // Semaphore: at most 2 database queries at once, whoever calls
  const dbPool = yield* Semaphore.make(2)
  const inFlight = yield* Ref.make(0)
  const maxInFlight = yield* Ref.make(0)

  const query = (sql: string) =>
    Semaphore.withPermits(dbPool, 1)(Effect.gen(function* () {
      const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1)
      yield* Ref.update(maxInFlight, (m) => Math.max(m, now))
      yield* Effect.sleep("5 millis")
      yield* Ref.update(inFlight, (n) => n - 1)
      return sql + " ok"
    }))

  // Launch everything at once; the semaphore, not the option, enforces the limit
  const results = yield* Effect.all(
    ["select 1", "select 2", "select 3", "select 4", "select 5"].map(query),
    { concurrency: "unbounded" }
  )
  console.log(results.join(" | "))
  console.log("max concurrent queries:", yield* Ref.get(maxInFlight))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

The Effect.all call is unbounded, but only 2 queries run at once. The limit lives with the resource. A second, unrelated Effect.all elsewhere that also calls query shares the same 2 permits. Try Semaphore.make(1) to run every query 1 at a time.

Lesson 11. Semaphore in depth: mutex, weighted permits, skip when busy #

A Semaphore has 3 more uses that the last lesson did not show.

1. A mutex. A semaphore with 1 permit is a lock. Only 1 fiber at a time can run the code inside withPermits. Use it when you must read a value, wait, and write it back, and the value lives outside a Ref. Examples are a file, a plain variable from old code, or a client library that is not safe for concurrent calls.

2. Weighted permits. A fiber can take more than 1 permit. A job that costs 3 permits waits until 3 permits are free at the same time. This lets a large job and small jobs share 1 pool. The runtime gives the permits back when the job ends, also on failure or interrupt.

3. Skip when busy. withPermitsIfAvailable does not wait. If the permits are free, it runs the effect and returns Option.some(result). If they are not free, it returns Option.none() at once. Use it for work that must not pile up, for example a metric flush or a cache refresh that is already in progress.

Call Waits? Result type Use it when
withPermits(sem, n)(effect) Yes A The work must run, and it must wait its turn
withPermitsIfAvailable(sem, n)(effect) No Option<A> The work is optional if another fiber already does it
take(sem, n) and release(sem, n) Yes number Manual control. Caution: a failure between the 2 calls keeps the permit
import { Effect, Option, Ref, Semaphore } from "effect"

const program = Effect.gen(function* () {
  // 1. Mutex: 1 permit protects a read-wait-write on a plain variable
  let balance = 100
  const lock = yield* Semaphore.make(1)
  const withdraw = (amount: number) =>
    Semaphore.withPermits(lock, 1)(Effect.gen(function* () {
      const current = balance
      yield* Effect.sleep("1 millis")       // without the lock, other fibers run here
      balance = current - amount
    }))
  yield* Effect.forEach([10, 20, 30], withdraw, { concurrency: "unbounded", discard: true })
  console.log("balance:", balance)          // 40. Without the lock: 90, 80, or 70

  // 2. Weighted permits: "big" needs 3 of 4 slots, so it waits for "small-a" to end
  const slots = yield* Semaphore.make(4)
  const order = yield* Ref.make<Array<string>>([])
  const job = (name: string, cost: number, ms: number) =>
    Semaphore.withPermits(slots, cost)(
      Effect.sleep(ms).pipe(Effect.andThen(Ref.update(order, (xs) => [...xs, name])))
    )
  yield* Effect.all(
    [job("small-a", 1, 5), job("small-b", 1, 25), job("big", 3, 1)],
    { concurrency: "unbounded" }
  )
  console.log("finish order:", (yield* Ref.get(order)).join(", "))

  // 3. Skip when busy: the second flush finds no permit and returns none at once
  const flushLock = yield* Semaphore.make(1)
  const flush = Semaphore.withPermitsIfAvailable(flushLock, 1)(
    Effect.sleep("5 millis").pipe(Effect.as("flushed"))
  )
  const [first, second] = yield* Effect.all([flush, flush], { concurrency: "unbounded" })
  console.log("first:", Option.getOrElse(first, () => "skipped"), "| second:", Option.getOrElse(second, () => "skipped"))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).
Notice

Notice that "big" ends before "small-b" although "big" started last. It waited only until 3 permits were free, not until the pool was empty. Try to change the cost of "big" to 4. Now it must wait for both small jobs, and the order changes. Then remove the lock in part 1: the balance is wrong because the 3 fibers read 100 at the same time.

Do and don't #

DoDon'tWhy
Keep the Fiber handle from Effect.forkChild and call Fiber.join or Fiber.await on it.Do not fork a child and return from the parent before the child is done.The runtime interrupts a child when its parent finishes, so the work of the child never completes.
Pass { concurrency: n } to Effect.forEach or Effect.all to limit the number of active effects.Do not fork each effect by hand and collect the fibers yourself.The option limits, collects in order, and interrupts the other effects on failure, and the manual version must do all of this itself.
Use a Semaphore when the limit belongs to a resource that many unrelated callers use.Do not create the semaphore with Semaphore.make inside the function that uses it.Each run creates a new semaphore with free permits, so no caller waits and the limit does nothing.
Use Effect.race when you want the first success, and Effect.raceFirst when any completion ends the race.Do not use Effect.raceFirst for a fallback between servers.A fast failure wins raceFirst, and the runtime interrupts the server that can still answer.
Process the TimeoutError from Effect.timeout with catchTag, or use Effect.timeoutOption or timeoutOrElse.Do not annotate an effect with a timeout as an Effect with an error type of never.A timeout is a real outcome, and the compiler rejects the annotation until the code decides what happens.
Wrap only the steps that must complete together in Effect.uninterruptible.Do not make a whole fiber with long waits uninterruptible.Fiber.interrupt must wait until the block finishes, and an uninterruptible infinite loop can never stop.
Use Queue.bounded for a producer and a consumer, and call Queue.end when the producer is done.Do not use an unbounded queue for a fast producer and a slow consumer.An unbounded queue accepts every value, so a fast producer fills the memory.
Run a program that uses Effect.forkScoped inside Effect.scoped.Do not give an effect with a Scope requirement to Effect.runPromise.The requirement is not never, so the program does not compile, and nothing closes the scope that stops the fiber.

Fix it #

Each program below is broken or incomplete. Make it print the expected output with zero type errors. Use hints before the solution.

1. A Fiber is not a value #

The program must print total: 21, but it does not compile. The program forks the fiber, but it never collects the result. Fix it. Do not change the console.log line.

expected output: total: 21
import { Effect, Fiber } from "effect"

const compute = Effect.gen(function* () {
  yield* Effect.sleep("2 millis")
  return 20
})

const program = Effect.gen(function* () {
  const result = yield* Effect.forkChild(compute)
  console.log("total:", result + 1)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

2. The option that does nothing #

The 6 lookups must run at most 3 at a time, and the program must print max in flight: 3. It does not compile. Without the type check, it prints 1. Fix the option that the program passes to Effect.forEach.

expected output: max in flight: 3
import { Effect, Ref } from "effect"

const program = Effect.gen(function* () {
  const inFlight = yield* Ref.make(0)
  const maxInFlight = yield* Ref.make(0)

  const lookup = (id: number) =>
    Effect.gen(function* () {
      const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1)
      yield* Ref.update(maxInFlight, (m) => Math.max(m, now))
      yield* Effect.sleep("5 millis")
      yield* Ref.update(inFlight, (n) => n - 1)
      return id
    })

  yield* Effect.forEach([1, 2, 3, 4, 5, 6], lookup, { parallel: 3 })
  console.log("max in flight:", yield* Ref.get(maxInFlight))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

3. The email that was never sent #

The handler must print request handled and then email sent, but the second line never appears. Fix the program so that it sends the email. Keep the order of the 2 lines.

expected output: request handled email sent
import { Effect, Fiber } from "effect"

const sendEmail = Effect.gen(function* () {
  yield* Effect.sleep("10 millis")
  console.log("email sent")
})

const handleRequest = Effect.gen(function* () {
  yield* Effect.forkChild(sendEmail)
  console.log("request handled")
})

Effect.runPromise(handleRequest)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

4. A timeout is an error until you handle it #

priceWithTimeout is declared as an Effect that cannot fail. The timeout makes this false, and the program does not compile. Do not change the type annotation. Make the program return cached price on a timeout, so that it prints price: cached price.

expected output: price: cached price
import { Effect } from "effect"

const slowLookup = Effect.sleep("50 millis").pipe(Effect.as("fresh price"))

const priceWithTimeout: Effect.Effect<string> = Effect.timeout(slowLookup, "5 millis")

const program = Effect.gen(function* () {
  const price = yield* priceWithTimeout
  console.log("price:", price)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

5. Never leave a half-written record #

A record is only valid with both a header and a body. The runtime interrupts the writer after 2 ms, between the 2 write steps, and leaves a half-written record. Make the 2 write steps safe from interrupts. The output must be write header, write body, writer stopped. The writer must still be interruptible after the record is complete.

expected output: write header write body writer stopped
import { Effect, Fiber } from "effect"

const writeRecord = Effect.gen(function* () {
  console.log("write header")
  yield* Effect.sleep("10 millis")
  console.log("write body")
  yield* Effect.sleep("50 millis")
  console.log("archived")
})

const program = Effect.gen(function* () {
  const writer = yield* Effect.forkChild(writeRecord)
  yield* Effect.sleep("2 millis")
  yield* Fiber.interrupt(writer)
  console.log("writer stopped")
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

6. A limit that limits nothing #

The database pool must permit at most 2 concurrent queries, so the program must print max concurrent: 2. It prints 5. Fix the code so that the semaphore limits the queries.

expected output: max concurrent: 2
import { Effect, Ref, Semaphore } from "effect"

const program = Effect.gen(function* () {
  const inFlight = yield* Ref.make(0)
  const maxInFlight = yield* Ref.make(0)

  const query = (id: number) =>
    Effect.gen(function* () {
      const pool = yield* Semaphore.make(2)
      return yield* Semaphore.withPermits(pool, 1)(Effect.gen(function* () {
        const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1)
        yield* Ref.update(maxInFlight, (m) => Math.max(m, now))
        yield* Effect.sleep("5 millis")
        yield* Ref.update(inFlight, (n) => n - 1)
        return id
      }))
    })

  yield* Effect.all([1, 2, 3, 4, 5].map(query), { concurrency: "unbounded" })
  console.log("max concurrent:", yield* Ref.get(maxInFlight))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

7. The fast failure that won the race #

We want the first successful answer from 2 servers. The primary server fails after 2 ms. The backup server answers after 20 ms. The program must print backup answered, but it fails with primary down. Fix the race.

expected output: backup answered
import { Effect } from "effect"

const primary = Effect.sleep("2 millis").pipe(Effect.andThen(Effect.fail("primary down")))
const backup = Effect.sleep("20 millis").pipe(Effect.as("backup answered"))

const program = Effect.gen(function* () {
  const answer = yield* Effect.raceFirst(primary, backup)
  console.log(answer)
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

8. A scoped fiber needs a scope #

The heartbeat must run while the program processes the request, and stop when the request is done. The program does not compile, because a requirement is missing at the point where you run it. Fix the last line only. The output must be heartbeat started, request done, heartbeat stopped.

expected output: heartbeat started request done heartbeat stopped
import { Effect } from "effect"

const heartbeat = Effect.gen(function* () {
  console.log("heartbeat started")
  yield* Effect.sleep("1 second")
}).pipe(Effect.onInterrupt(() => Effect.sync(() => console.log("heartbeat stopped"))))

const program = Effect.gen(function* () {
  yield* Effect.forkScoped(heartbeat)
  yield* Effect.sleep("5 millis")
  console.log("request done")
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

9. The permit that never comes back #

The first call fails on purpose. After that, the second call never gets a permit and the program prints hung. Change how the semaphore is used so a failure gives the permit back, and the program prints done for the second call.

expected output: first: failed second: done
import { Effect, Option, Result, Semaphore } from "effect"

const program = Effect.gen(function* () {
  const lock = yield* Semaphore.make(1)

  const risky = (fail: boolean) =>
    Effect.gen(function* () {
      yield* Semaphore.take(lock, 1)
      if (fail) yield* Effect.fail("boom")
      yield* Semaphore.release(lock, 1)
      return "done"
    })

  const first = yield* Effect.result(risky(true))
  console.log("first:", Result.isFailure(first) ? "failed" : "ok")

  const second = yield* risky(false).pipe(Effect.timeoutOption("50 millis"))
  console.log("second:", Option.isSome(second) ? second.value : "hung")
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

10. The queue that cannot end #

The producer calls Queue.end, and the consumer stops on Done, but the file does not compile. The type of the queue does not permit an end signal. Fix the type of the queue. Do not change the producer or the consumer. The program must print total: 60.

expected output: total: 60
import { Effect, Fiber, Queue } from "effect"

const program = Effect.gen(function* () {
  const jobs = yield* Queue.bounded<number>(2)

  const producer = yield* Effect.forkChild(Effect.gen(function* () {
    yield* Queue.offerAll(jobs, [10, 20, 30])
    yield* Queue.end(jobs)
  }))

  const consumer = yield* Effect.forkChild(Effect.gen(function* () {
    let total = 0
    yield* Effect.gen(function* () {
      total += yield* Queue.take(jobs)
    }).pipe(Effect.forever, Effect.catchTag("Done", () => Effect.void))
    return total
  }))

  yield* Fiber.join(producer)
  console.log("total:", yield* Fiber.join(consumer))
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

11. The gate that stayed closed #

Workers a and b wait until the cache is warm. Worker c arrives after the warm-up, and it must not wait. The program prints c timed out. Change 1 call so that the program prints a served, b served and then c served.

expected output: a served, b served c served
import { Effect, Fiber, Latch, Option } from "effect"

const program = Effect.gen(function* () {
  const ready = yield* Latch.make()

  // 2 workers wait before the cache is warm
  const worker = (name: string) => Latch.whenOpen(ready, Effect.succeed(name + " served"))
  const early = yield* Effect.forEach(["a", "b"], (n) => Effect.forkChild(worker(n)))

  yield* Effect.sleep("5 millis")            // cache warm-up
  yield* Latch.release(ready)
  console.log((yield* Fiber.joinAll(early)).join(", "))

  // a worker that arrives after the warm-up must not wait
  const late = yield* worker("c").pipe(Effect.timeoutOption("5 millis"))
  console.log(Option.isSome(late) ? late.value : "c timed out")
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

Build it #

Write the program from the spec. The output must match exactly.

1. Worker pool draining a queue #

Build a pool of 3 workers that process 6 jobs from a bounded queue.

  1. Create Queue.bounded<Job, Cause.Done>(10), where Job = { id: number }. Offer jobs 1 to 6 with Queue.offerAll. Then call Queue.end, so that the workers know when to stop.
  2. worker(n) loops: it takes a job, "processes" it with a sleep of 5 ms, and pushes the string "job-<id> -> <id * 2>" into the shared results Ref. During the process step, count the active workers with the busy/maxBusy refs in the same way as lesson 4. When Queue.take fails with Done, the worker stops. Use Effect.forever plus Effect.catchTag("Done", ...).
  3. Run 3 workers with Effect.forEach and concurrency: "unbounded". Then print the results sorted, 1 per line, and then a summary line.

Exact output:

job-1 -> 2
job-2 -> 4
job-3 -> 6
job-4 -> 8
job-5 -> 10
job-6 -> 12
processed 6 jobs, max busy workers: 3
expected output: job-1 -> 2 job-2 -> 4 job-3 -> 6 job-4 -> 8 job-5 -> 10 job-6 -> 12 processed 6 jobs, max busy workers: 3
import { Cause, Effect, Queue, Ref } from "effect"

type Job = { id: number }

const program = Effect.gen(function* () {
  const results = yield* Ref.make<Array<string>>([])
  const busy = yield* Ref.make(0)
  const maxBusy = yield* Ref.make(0)

  // TODO: create the bounded queue, offer jobs 1..6, then end it

  const worker = (n: number) =>
    Effect.gen(function* () {
      // TODO: loop: take a job, sleep 5ms, record "job-<id> -> <id * 2>"
      // TODO: stop when take fails with Done
    })

  // TODO: run 3 workers concurrently and wait for all of them

  // TODO: print sorted results, then the summary line
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

2. First responder wins, losers clean up #

Query 4 mirrors at once and use the first successful answer. The runtime must cancel every mirror that still runs when the winner arrives, and you must prove it.

  1. mirror(name, ms) sleeps ms and succeeds with name. Attach Effect.onInterrupt, so that a cancelled mirror adds its name to a shared cancelled Ref.
  2. broken is a mirror that fails after 1 ms with "backup offline". It must not decide the race.
  3. Race mirror("eu", 60), mirror("us", 5), mirror("asia", 40), and broken with Effect.raceAll.
  4. Print the winner. Then print the cancelled names sorted and joined with ", ".

Exact output:

winner: us
cancelled: asia, eu

The failed mirror is not in the cancelled list. It finished on its own, and the runtime did not interrupt it.

expected output: winner: us cancelled: asia, eu
import { Effect, Ref } from "effect"

const program = Effect.gen(function* () {
  const cancelled = yield* Ref.make<Array<string>>([])

  // TODO: mirror(name, ms) succeeds with name after ms, records itself on interrupt
  const mirror = (name: string, ms: number) => Effect.succeed(name)

  // TODO: broken fails with "backup offline" after 1ms

  // TODO: race all four, print "winner: <name>"
  // TODO: print "cancelled: <sorted names joined by ', '>"
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

3. Rate-limited batch downloader #

Download 6 files with at most 2 active downloads, independent of how the program starts the downloads.

  1. files is the fixed list [["a.txt", 3], ["b.txt", 5], ["c.txt", 2], ["d.txt", 8], ["e.txt", 1], ["f.txt", 4]] of [name, bytes].
  2. Create a Semaphore with 2 permits. download(name, bytes) runs under 1 permit. It increments inFlight, records the maximum in maxInFlight, sleeps 5 ms, decrements inFlight, and returns bytes.
  3. Start all 6 downloads with Effect.forEach and concurrency: "unbounded", so that the semaphore, not the option, is the limiter. The results must come back in input order.
  4. Print 1 line per file in input order, then the total, then the maximum number of concurrent downloads.

Exact output:

a.txt: 3 bytes
b.txt: 5 bytes
c.txt: 2 bytes
d.txt: 8 bytes
e.txt: 1 bytes
f.txt: 4 bytes
total: 23 bytes
max concurrent downloads: 2
expected output: a.txt: 3 bytes b.txt: 5 bytes c.txt: 2 bytes d.txt: 8 bytes e.txt: 1 bytes f.txt: 4 bytes total: 23 bytes max concurrent downloads: 2
import { Effect, Ref, Semaphore } from "effect"

const files: Array<[string, number]> = [
  ["a.txt", 3], ["b.txt", 5], ["c.txt", 2], ["d.txt", 8], ["e.txt", 1], ["f.txt", 4]
]

const program = Effect.gen(function* () {
  const inFlight = yield* Ref.make(0)
  const maxInFlight = yield* Ref.make(0)

  // TODO: create a semaphore with 2 permits

  // TODO: download(name, bytes) under one permit, tracking inFlight / maxInFlight
  const download = (name: string, bytes: number) => Effect.succeed(bytes)

  // TODO: run all downloads with concurrency "unbounded", keep input order
  // TODO: print "<name>: <bytes> bytes" per file, "total: <sum> bytes", and the max line
})

Effect.runPromise(program)
⌘/Ctrl + Enter
Press Run (or ⌘/Ctrl+Enter in the editor).

Recall #

Answer in your head first, then reveal. Come back to these tomorrow.

What is a fiber, and how can 2 fibers interleave on a single JavaScript thread? #

A fiber is a lightweight thread of execution that the Effect runtime manages. Fibers are cooperative. A fiber runs until it yields (a sleep, an async step, or Effect.yieldNow). Then another fiber gets a turn. Only 1 fiber runs at any instant, but their steps interleave at the yield points.

What happens to a fiber that starts with `Effect.forkChild` if its parent finishes first? #

The runtime interrupts it. The parent limits the lifetime of a child. Use Fiber.join or Fiber.await to make the parent wait. Use Effect.forkScoped to tie the fiber to a Scope instead. Use Effect.forkDetach when the fiber must continue after the parent (then you own its cleanup).

What is the type of `Effect.forkChild(Effect.fail("x") as Effect.Effect<number, string>)`? #

Effect<Fiber<number, string>, never, never>. The fork itself never fails and needs nothing. The success value is a handle, and its own value and error types are those of the forked effect. Fiber.join on it gives back Effect<number, string>.

Which function permits at most 3 concurrent calls to a database, when the calls come from many unrelated places in the program? #

A Semaphore with 3 permits. Wrap each call in Semaphore.withPermits(sem, 1). The concurrency option only limits 1 forEach/all call. A shared semaphore limits the resource itself, for every caller.

What is the difference between `Effect.race` and `Effect.raceFirst`? #

race returns the first success and ignores earlier failures (it fails only when both fail). raceFirst returns the effect that completes first, success or failure. In both cases the runtime interrupts the other fiber.

How does `Effect.timeout` differ from `Effect.timeoutOption`, and what happens to the slow effect? #

timeout fails with a TimeoutError. The error type includes it, so you must process it, for example with catchTag("TimeoutError", ...). timeoutOption succeeds with Option.none() instead. In both cases the runtime interrupts the slow effect when the time runs out.

Is an interrupt an error? How does it appear in a `Cause`? #

No. It is a third outcome, next to success and failure. The Cause has a reason with the tag "Interrupt", Cause.hasFails is false, and ordinary error handlers do not catch it. Finalizers such as onInterrupt and ensuring still run. Effect.uninterruptible postpones an interrupt until a block has finished.

Deferred, Queue, or PubSub: which one do you use in each case? (a) 1 fiber waits for a config load. (b) 3 listeners must all receive 5 events. (c) Each work item gets processed 1 time. #

(a) Deferred: a value that you set 1 time, and that all waiters share. (b) PubSub: broadcast, every subscriber gets every message. (c) Queue: each item goes to exactly 1 taker, with backpressure if the queue is bounded.