On Effect Office Hours, people often ask me to “roast” their Effect codebases. This takes some nerve on my part, given that my own GitHub profile is public.
Rather than give anyone a reason to look at my code, let’s outsource the roasting. We’ll build “Roast My Repo”, a hypothetical app with AI reviewers trained to bring out your inner imposter syndrome.
Reference Counting
To gain access to your code, the app will clone your repository into a temporary folder. We will start with two AI reviewers: one examines your dependency graph and the other reviews your code. Both will need to access the same repository, so they will share a checkout instead of cloning the repository twice.
But if both review agents are sharing one checkout, how do we know when we can delete it?
We can track this with a count. Each reviewer adds one when it starts using the checkout and subtracts one when it’s done. When the count reaches zero, nobody needs the files anymore, so we can safely remove the directory.
Try connecting and disconnecting the reviewers below to see this in action.
Click a reviewer to connect or release.
No checkout. Reference count: 0. Connect a reviewer to create a checkout.
This is called reference counting.
Why a Map?
So far, our reviewers have all been reviewing the same repository. But what happens when a user submits a different one?
Suppose we’re reviewing acme/api and acme/website. They need separate checkouts, each with its own reference count.
Select Reviewer A, then click acme/api to connect them. Do the same with Reviewer B and acme/website. Now click acme/website again to release Reviewer B’s checkout. Watch it disappear while Reviewer A’s checkout stays available.
Select a reviewer, then click a repository to connect or release.
Reviewer A selected. Neither repository has a checkout.
We can keep track of these checkouts in a map, using the repository URL and commit as the key. Each entry holds a checkout and its reference count. Reviewers requesting the same key share that checkout. When its count reaches zero, we delete the checkout and remove the entry.
Select acme/api beneath Reviewer A to add it to the map. Select it beneath Reviewer B too: the reference count increases, but there’s still only one entry. Deselect it beneath both reviewers and watch the entry disappear.
Choose a repository beneath a reviewer. Click it again to release.
The map is empty. Choose a repository beneath either reviewer to create a checkout.
Using a map allows us to access shared resources by key. Reference counting lets us automatically clean them up when they’re no longer in use. Effect’s RcMap combines these two ideas into a single data structure.
Let’s use RcMap to implement the checkout sharing we’ve just seen.
The Git Service
Defining the service
Let’s put our checkouts behind a Git service. Its checkout method takes a repository URL and desired commit and returns the path to a local checkout.
We’ll group the URL and commit in a Repository class. This is what we’ll pass to checkout and use as the key in our map:
import { Data } from "effect"
export class Repository extends Data.Class<{ readonly url: string readonly commit: string}> { get identifier(): string { return `${this.url}@${this.commit}` }}RcMap compares keys using Effect’s equality rules. Two separately created Repository values with the same URL and commit count as the same key. In Effect v4, plain objects also have structural equality by default, so we could use those instead, but Data.Class is useful here for grouping the fields and getter together.
Inside the Git service, we create the map using the RcMap.make constructor. We must define a lookup function, which will receives a Repository and returns an Effect that acquires the checkout.
For this example, we’ll create a temporary directory and log where the clone would go. A full implementation would run Git before returning the path.
import { Context, Data, Effect, FileSystem, Layer, RcMap, Scope } from "effect"import { Repository } from "./repository.ts"
export class GitError extends Data.TaggedError("GitError")<{ readonly repository: Repository readonly cause: unknown}> {}
export interface GitService { readonly checkout: ( repository: Repository, ) => Effect.Effect<string, GitError, Scope.Scope>}
export class Git extends Context.Service<Git, GitService>()("Git", { make: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem
const checkouts = yield* RcMap.make({ lookup: (repository: Repository) => fs.makeTempDirectoryScoped({ prefix: repository.identifier }).pipe( Effect.tap((dir) => Effect.log(`Cloning to: ${dir}`)), Effect.mapError((cause) => new GitError({ repository, cause })), ), })
return { checkout: (repository) => RcMap.get(checkouts, repository), } }),}) { static readonly layer = Layer.effect(this, this.make)}Acquiring a checkout
Constructing the map doesn’t create any directories yet. When checkout is invoked, it delegates to RcMap.get. If the key isn’t in the map, RcMap runs the lookup function which will create the checkout and insert an entry into the map. Otherwise, the reviewer shares the existing checkout.
RcMap.get("acme/api") Click to request your first checkout.
Notice that the checkout method provided by the Git service requires a Scope.
export interface GitService { readonly checkout: ( repository: Repository, ) => Effect.Effect<string, GitError, Scope.Scope>}That’s how RcMap tracks how long each reviewer needs the files. When the Scope associated with a given call to checkout closes, the reference count for that map entry is decremented. When no references remain, the temporary directory is removed.
RcMap.get("acme/api") Click to request your first checkout.
Running reviews
Let’s run two reviews against the same repository. Each review gets its own scope, so it can release the checkout as soon as it’s done.
import { NodeRuntime, NodeServices } from "@effect/platform-node"import { Effect, Layer } from "effect"import { Git } from "./git.ts"import { Repository } from "./repository.ts"
declare const reviewDependencies: (path: string) => Effect.Effect<void, Error>declare const reviewCode: (path: string) => Effect.Effect<void, Error>
const MainLayer = Git.layer.pipe(Layer.provide(NodeServices.layer))
const program = Effect.gen(function* () { const git = yield* Git
const repository = new Repository({ url: "https://github.com/acme/api.git", commit: "a1b2c3d", })
const dependencyReview = git.checkout(repository).pipe( Effect.flatMap(reviewDependencies), Effect.scoped, // Close the scope after the review completes )
const codeReview = git.checkout(repository).pipe( Effect.flatMap(reviewCode), Effect.scoped, // Close the scope after the review completes )
// Run the reviews concurrently yield* Effect.all([dependencyReview, codeReview], { concurrency: "unbounded", })})
program.pipe(Effect.provide(MainLayer), NodeRuntime.runMain)Effect.all runs both reviews concurrently against the same repository, so if one is still acquiring the checkout, the other waits for it. Once it’s ready, both receive the same directory path.
Placing Effect.scoped after Effect.flatMap keeps the scope open while the review runs. When the review finishes, the scope closes and releases its reference to the checkout.
Say the dependency review finishes first. Its scope closes, taking the reference count from two to one. The code review carries on using the directory. When that finishes, the count reaches zero and RcMap removes the directory. Neither review needs to know what the other is doing.
Keeping checkouts alive
Both reviews finish, their associated Scopes are closed, and the checkout directory is deleted from the file system. A second later, another reviewer asks for the same repository. Now we have to re-clone the entire repository again.
This is pretty inefficient.
Luckily, RcMap provides us with the ability to keep entries around for a little while before cleaning them up. Let’s set the map’s idleTimeToLive to five seconds, giving another review a chance to reuse the same checkout before it’s deleted:
const checkouts = yield * RcMap.make({ lookup: (repository: Repository) => fs.makeTempDirectoryScoped({ prefix: repository.identifier }).pipe( Effect.tap((dir) => Effect.log(`Cloning to: ${dir}`)), Effect.mapError((cause) => new GitError({ repository, cause })), ), idleTimeToLive: "5 seconds", })Now, when the reference count for a given checkout reaches zero, the checkout sticks around for five more seconds before being cleaned up.
Close all three scopes below to start the countdown, then click Get checkout before it reaches zero. You’ll get the same directory back. Close the new scope and wait five seconds to see it removed.
RcMap.get("acme/api") Click to request your first checkout.
Reusing existing checkouts
Suppose we only want to run an optional review if there’s already a checkout for that repository. If there isn’t, we’ll skip the review. Calling get won’t work here: it creates a checkout when the key is missing. We need a way to request an existing entry without creating one.
RcMap.getOption does exactly that. It returns Some(path) for an existing checkout, or None if the key is missing. If the checkout is still being acquired, it waits for the path rather than returning None.
Try clicking Get existing below with an empty map. Then, click Get checkout followed by Get existing and observe the difference.
RcMap.get("acme/api") RcMap.getOption("acme/api") Click to request your first checkout.
Extending the idle window
Suppose we added a scheduler that assigns reviews to a pool of AI workers. The dependency review has finished, but the code review is still queued, waiting for a free worker. Nothing is using the checkout, so its idle countdown has started. We’d like to keep those files around for the queued review rather than clone the repository again.
The scheduler can call RcMap.touch periodically to reset the idle countdown while the review waits for a worker:
yield * RcMap.touch(checkouts, repository)Limiting checkouts
Keeping idle checkouts around saves us from cloning them again, but they still take up disk space. We can limit how many entries the map holds by setting the map’s capacity:
const checkouts = yield * RcMap.make({ lookup: (repository: Repository) => fs.makeTempDirectoryScoped({ prefix: repository.identifier }).pipe( Effect.tap((dir) => Effect.log(`Cloning to: ${dir}`)), Effect.mapError((cause) => new GitError({ repository, cause })), ), idleTimeToLive: "5 seconds", capacity: 10, })Attempting to add a new entry to a map that is at capacity will fail with a Cause.ExceededCapacityError.
The map below has a capacity of two entries. Request acme/api and acme/website, then try acme/docs. The third request fails, but requesting acme/api again still works because it shares an existing entry.
Choose a repository to acquire a checkout.
Click scopes to close them. Idle time to live: 5 seconds.
With a capacity set, RcMap.get can fail with Cause.ExceededCapacityError when a new entry would exceed the limit. Our GitService.checkout signature needs to include that error unless we map it to our existing GitError:
import { Cause, Effect, Scope } from "effect"
export interface GitService { readonly checkout: ( repository: Repository, ) => Effect.Effect< string, GitError | Cause.ExceededCapacityError, Scope.Scope >}I’d keep ExceededCapacityError in the signature so callers can decide what to do when the map is full, such as re-queueing the review or retrying after a delay.
Invalidating checkouts
Suppose a review runs a tool that modifies files in the checkout. The repository URL and commit haven’t changed, but the files on disk have. We’d like the next review to get a clean copy.
Inside our Git service, we can remove the entry from the map with RcMap.invalidate:
yield * RcMap.invalidate(checkouts, repository)The next time get is invoked for that repository, the map’s lookup function will be called and a new checkout will be created.
But what happens to reviews that were using the checkout we invalidated? Let’s take a look.
Click Invalidate below, then Get checkout. Notice what happens to the scopes referencing the invalidated checkout.
acme/api @ a1b2c3dNo entry
Temporary checkoutClick a scope to close it. Idle time to live: 5 seconds.
Invalidated map entries keep their references, so in our case the old checkout will remain on the file system until the last Scope associated with it is closed.
Conclusion
Effect’s reference-counted data structures are exceptionally useful in situations where you have multiple consumers that all need shared access to the same scoped resource.
We focused on RcMap in this post, which manages shared resources by key. Effect also provides RcRef for when you only need to share a single resource.
We hope you enjoyed this edition of Module of the Week! Until the next one - Happy Effecting!