Skip to content

LayerMap preloadKeys immediately releases preloaded resources with default idle TTL (v3 regression) #8493

Description

@joaoeira

What version of Effect is running?

effect@4.0.0-rc.117, tested with Bun 1.2.21 and Node.js v24.7.0 on macOS arm64.

The preload retention behavior was also compared against effect@3.22.2.

What steps can reproduce the bug?

Create a resource-owning LayerMap with preloadKeys and the default idleTimeToLive. The resource is acquired and immediately released before LayerMap.make returns. The first actual use has to build it again.

In a new directory:

npm init -y
npm install --save-exact effect@4.0.0-rc.117

Save as repro.ts and run bun repro.ts (or node repro.ts with Node.js v24.7.0):

import { Effect, Layer, LayerMap, RcMap } from "effect"

const program = Effect.gen(function* () {
  const events: Array<string> = []
  const map = yield* LayerMap.make(
    (key: string) => Layer.effectDiscard(Effect.acquireRelease(
      Effect.sync(() => { events.push(`open ${key}`) }),
      () => Effect.sync(() => { events.push(`close ${key}`) }),
    )),
    { preloadKeys: ["tenant-a"] },
  )

  console.log("after make:", JSON.stringify(events))
  console.log("cached keys:", JSON.stringify(Array.from(yield* RcMap.keys(map.rcMap))))

  yield* Effect.scoped(map.contextEffect("tenant-a"))
  console.log("after first use:", JSON.stringify(events))
})

Effect.runPromise(Effect.scoped(program))

What is the expected behavior?

Preloaded resources remain available for subsequent use rather than being immediately discarded. In particular, the v3 behavior retained a reference in the LayerMap's owning scope, keeping preloaded resources alive until that scope closed (unless explicitly invalidated).

With that behavior, the output before the owning scope closes would be:

after make: ["open tenant-a"]
cached keys: ["tenant-a"]
after first use: ["open tenant-a"]

The resource would be released when the owning scope closes.

What do you see instead?

Both Bun and Node print:

after make: ["open tenant-a","close tenant-a"]
cached keys: []
after first use: ["open tenant-a","close tenant-a","open tenant-a","close tenant-a"]

Default preloading therefore adds an extra acquire/release cycle without keeping the service ready for its first use. It still detects acquisition failures during construction, but does not retain the resource.

Additional information

Cause and v3 comparison

The rc.117 preload loop is:

for (const key of options.preloadKeys) {
  yield* Effect.scoped(RcMap.get(rcMap, key))
}

Each temporary scope immediately releases its reference. RcMap defaults idleTimeToLive to zero, so its last-reference release removes and closes the entry.

In v3.22.2, the loop yields RcMap.get(rcMap, key) without the temporary Effect.scoped; the reference is held by the owning scope instead. I verified that v3 acquires once, retains the cached key across the first use, and releases only at owner-scope shutdown.

Coverage

Deterministic tests against rc.117 also confirmed:

  • LayerMap.fromRecord({ ... }, { preload: true }) has the same default-TTL behavior.
  • LayerMap.Service with preloadKeys has the same behavior.
  • With a finite idle TTL, a preload is retained only until idle expiry unless another borrower retains/reuses it.
  • With infinite idle TTL, a preload remains cached until invalidation or map shutdown.
  • A failed preload still fails construction, so construction-time failure checking is not the broken part.

As a temporary workaround, an infinite idle TTL retains preloads, but it also changes the retention policy for other entries; it is not equivalent to retaining only preloaded keys in the owning scope.

Related existing test

PR #6871 addressed preload options having no runtime effect. The current make preloads the requested keys test checks that acquisition occurred, but does not check whether those resources remain retained afterward. This report concerns that retention gap, not the already-covered absence of acquisition.

If changing preload lifetime from v3 was intentional, clarification of that policy would help. With the current defaults, however, the selected resources have already been closed when construction returns.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions