Skip to content

Sync resolvers - #3732

Open
DaddyWarbucks wants to merge 2 commits into
feathersjs:dovefrom
DaddyWarbucks:sync-resolvers
Open

DaddyWarbucks wants to merge 2 commits into
feathersjs:dovefrom
DaddyWarbucks:sync-resolvers

Conversation

@DaddyWarbucks

Copy link
Copy Markdown
Member

Summary

Allow synchronous property resolvers, converters, and virtual resolvers to execute without creating unnecessary promises or introducing internal microtask boundaries.

Previously, resolver execution treated synchronous work as asynchronous at several layers:

  • resolveProperty() always returned a promise.
  • Property iteration used Promise.all(propertyList.map(async ...)), including for properties that only needed copying.
  • convert() always returned a promise, even when no converter was configured.
  • virtual() wrapped every callback in an async function.

These costs repeat across properties, records, and resolver stages. For small synchronous transformations, promise allocation and scheduling can account for a meaningful part of execution time.

The updated implementation handles plain values immediately and waits for asynchronous results when necessary.

Implementation

Synchronous property execution

Move property execution into a private resolvePropertyValue() helper that can return either a value or a promise.

Keep public resolveProperty() asynchronous so existing callers can continue using .then(), .catch(), or await.

Replace the asynchronous property mapping with a loop that:

  1. Copies properties without resolvers directly.
  2. Applies synchronous resolver results immediately.
  3. Collects asynchronous property work into a pending list.
  4. Awaits the pending list only when asynchronous work exists.

Independent asynchronous properties continue to start concurrently. Synchronous throws and asynchronous rejections continue to contribute to the existing property-error aggregation.

Preserve property overrides

Use the internal helper only when the instance uses the default public implementation:

const resolved =
  this.resolveProperty === Resolver.prototype.resolveProperty
    ? this.resolvePropertyValue(name, data, context, status)
    : this.resolveProperty(name, data, context, status)

Subclass overrides and instance replacements of resolveProperty() continue to participate in resolution.

Synchronous conversion

Move conversion logic into a private convertValue() helper while keeping public convert() asynchronous.

Internally, resolve() uses the helper for the default implementation and calls overridden convert() methods when present:

const converted =
  this.convert === Resolver.prototype.convert
    ? this.convertValue(_data, context, status)
    : this.convert(_data, context, status)

const payload = isPromiseLike(converted) ? await converted : converted

When conversion returns immediately—or no converter exists—property resolution can begin without an intervening await. Asynchronous conversion still finishes before property resolution starts.

Synchronous virtuals

Remove async from the wrapper returned by virtual().

Synchronous virtual callbacks now return their values directly, allowing them to use the property fast path. Asynchronous callbacks continue returning promises.

The virtual marker remains intact, preserving virtual-property identification and $select handling.

Promise detection

Add an internal isPromiseLike() helper to recognize native promises and thenables. Execution follows the returned value rather than relying on whether a callback was declared async.

Compatibility and observable behavior

Public resolve(), resolveProperty(), and convert() continue returning promises.

Property selection, omission of undefined values, circular-dependency checks, conversion status, and property-error aggregation remain in place.

Two observable details change:

  • Microtask interleaving and property insertion order: synchronous results are assigned immediately. Output key order can differ from the previous completion-dependent order. Resolver output property order has no documented guarantee.
  • Direct invocation of virtual wrappers: a synchronous virtual callback can now return a plain value or throw synchronously. The declared return type already permits plain values. Calls through resolve() continue returning promises and handling property errors.

This change is limited to packages/schema/src/resolver.ts. It does not modify the hook dispatcher or service-integration hooks.

Benchmark setup

Compare the resolver implementation from these revisions:

Revision Commit
Before 8c007bc43e30106bee7158e7f699089232e6e765
After 21be90daf528967ad7def7dbc6ec05f30840e7bd

Environment:

Setting Value
CPU Apple M3 Pro
Architecture ARM64
Operating system Darwin 23.6.0
Node.js v22.22.0

Methodology

  • Run seven fresh-process samples per revision.
  • Alternate revision order between rounds.
  • Rotate and reverse scenario order to reduce ordering effects.
  • Warm each scenario for at least 100 ms.
  • Target approximately 300 ms of measured execution per scenario.
  • Transpile TypeScript and construct fixtures and resolver instances before timing.
  • Use the same installed dependencies and service-integration code for both revisions, isolating resolver implementation changes.
  • Run with --expose-gc, collecting garbage before each measured scenario.
  • Keep promise-count instrumentation disabled during timing.
  • Report median elapsed time per operation and corresponding throughput.

Before and after outputs were checked for equivalent values after canonicalizing object key order. The service workload also checked equivalent external dispatch output.

Resolver workloads

The scenarios cover:

  • Copying selected properties without property resolvers.
  • Empty resolvers and synchronous conversion.
  • Synchronous properties and virtuals at different object sizes.
  • Mixed synchronous and asynchronous properties.
  • Entirely asynchronous properties.
  • A mixed workload with an actual scheduling boundary through setImmediate.

The asynchronous-property scenarios use immediately fulfilled promises without database or network work.

Benchmark results

Resolver execution

Times are median microseconds per resolver call. Speedup is before time divided by after time.

Scenario Before, µs After, µs Speedup Time reduction
Copy 10 selected fields from 100 0.751 0.350 2.15× 53.4%
Empty resolver, no converter 0.105 0.065 1.60× 37.6%
Synchronous converter only 0.105 0.077 1.36× 26.3%
1 synchronous property 0.438 0.249 1.76× 43.2%
10 synchronous properties 1.880 1.196 1.57× 36.4%
100 synchronous properties 24.350 15.506 1.57× 36.3%
1 synchronous virtual 0.476 0.252 1.89× 47.0%
10 synchronous virtuals 2.217 1.255 1.77× 43.4%
100 synchronous virtuals 27.325 18.137 1.51× 33.6%
10 synchronous properties + converter 1.914 1.200 1.60× 37.3%
10 properties, half asynchronous 2.060 1.630 1.26× 20.9%
100 properties, half asynchronous 26.152 20.992 1.25× 19.7%
10 asynchronous properties 2.118 1.902 1.11× 10.2%
100 asynchronous properties 26.668 24.507 1.09× 8.1%
10 properties, one using setImmediate 15.079 13.911 1.08× 7.7%

Memory-backed service

Each service call returns 100 records, each with 10 stored fields, through:

  1. A first result-resolver stage.
  2. A second result-resolver stage.
  3. An external-resolver stage.

Each stage contains five synchronous virtuals and one synchronous property resolver. The workload includes the memory adapter, hook dispatch, resolver integration, and external dispatch generation.

Concurrent calls Before, calls/sec After, calls/sec Speedup Time reduction per completed operation
1 919 1,409 1.53× 34.8%
8 630 1,231 1.95× 48.8%

At concurrency 1, average elapsed time per call decreased from approximately 1.089 ms to 0.710 ms.

The concurrency-8 results measure aggregate throughput. They do not represent individual request latency, and no latency percentiles were collected.

These workloads include no HTTP transport, database, or network costs. The results demonstrate resolver and in-process service improvements; they do not establish the same percentage improvement for applications dominated by external I/O.

Closes #3492

This branch has not been deployed

No deployments
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.

Resolver Performance

1 participant