Skip to content

Conversation

@seriousme
Copy link
Contributor

@robertsLando
As promised, this PR reduces the number of production dependencies to a bare minimum.

This PR replaces:

  1. "end-of-stream" by native "finished" from "node:stream"
  2. "fastfall" by a local "runfall"
  3. "fastparallel": by a local "runParallel"
  4. "fastseries": by a local "runSeries"
  5. "hyperid": by "randomUUID" from 'node:crypto' , hyperid might be faster (25M ops/second vs 12M ops/second) but also creates predictable ID's and since we only used "hyperid" to generate clientID's 12M ops/second should still be enough imo)
  6. "retimer": by a local "retimer" which is nearly as fast as the original "retimer" module with a difference of 1 second on 1M invocations.
  7. "reusify": by a local "ObjectPool" (btw: reusify was only used on "Enquers" and I am not sure how much performance this actually added as V8 has optimized object creation over time, but just to be sure I put in the ObjectPool)
  8. "uuid": by "randomUUID" from 'node:crypto' as it turned out that on nodeJS the "uuidv4" of "uuid" it is an alias for "randomUUID" from 'node:crypto' anyway.

The local variants listed above are all quite small, mostly because they only need to support one variant instead of the many variants that some external modules provide. Also over the years some patterns have become more easy to implement with modern Javascript.

This PR tries to keep the code changes to a minimum.
From here on, further optimizations might be possible, but I didn't want to make this PR too complex.

Kind regards,
Hans

@codecov
Copy link

codecov bot commented Sep 2, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.72%. Comparing base (2c6882e) to head (3a35a63).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1045      +/-   ##
==========================================
+ Coverage   99.44%   99.72%   +0.28%     
==========================================
  Files          15       15              
  Lines        1797     1837      +40     
==========================================
+ Hits         1787     1832      +45     
+ Misses         10        5       -5     
Flag Coverage Δ
unittests 99.72% <100.00%> (+0.28%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seriousme
Copy link
Contributor Author

Automated Benchmark testing suggests more than 10℅ perf decrease.
I will try to figure out what is causing this.

Kind regards,
Hans

@seriousme
Copy link
Contributor Author

seriousme commented Sep 2, 2025

It turns out the difference was actually positive instead of negative, tested on Codespaces using 4 cores.


Overall Benchmark Results

+x% is better, -x% is worse, current threshold to fail at -10%

Label Benchmark Config Average Units Percentage
main sender.js QoS=0, Cores=4 107096 msg/s 100%
reduce-production-dependencies sender.js QoS=0, Cores=4 124161 msg/s +15.93%
main receiver.js QoS=0, Cores=4 108229 msg/s 100%
reduce-production-dependencies receiver.js QoS=0, Cores=4 124055 msg/s +14.62%
main sender.js QoS=1, Cores=4 53724 msg/s 100%
reduce-production-dependencies sender.js QoS=1, Cores=4 56895 msg/s +5.90%
main receiver.js QoS=1, Cores=4 53727 msg/s 100%
reduce-production-dependencies receiver.js QoS=1, Cores=4 56893 msg/s +5.89%
main pingpong.js QoS=1, Cores=4, Score='perc95' 41 ms 100%
reduce-production-dependencies pingpong.js QoS=1, Cores=4, Score='perc95' 41 ms 100%

Benchmark Results for main

Benchmark Config Units Round 1 Round 2 Round 3 Round 4 Round 5 Round 6 Round 7 Round 8 Round 9 Round 10
sender.js QoS=0, Cores=4 msg/s 104709 102963 100781 116193 107521 111121 108203 98121 126638 94705
receiver.js QoS=0, Cores=4 msg/s 113003 105290 108320 102153 100046 112616 112741 107542 108220 112359
sender.js QoS=1, Cores=4 msg/s 50461 53588 54380 55006 52976 55709 54517 55805 51788 53013
receiver.js QoS=1, Cores=4 msg/s 50489 53579 54377 55014 52977 55703 54515 55809 51784 53020
pingpong.js QoS=1, Cores=4, Score='perc95' ms 41 41 41 41 41 41 41 41 41 41

Benchmark Results for reduce-production-dependencies

Benchmark Config Units Round 1 Round 2 Round 3 Round 4 Round 5 Round 6 Round 7 Round 8 Round 9 Round 10
sender.js QoS=0, Cores=4 msg/s 116256 119349 122598 124305 135292 132318 116559 117091 120768 137072
receiver.js QoS=0, Cores=4 msg/s 124809 116053 118338 131602 118724 137263 133331 119014 119005 122410
sender.js QoS=1, Cores=4 msg/s 56878 57472 56176 57224 56130 56366 56299 56725 58094 57588
receiver.js QoS=1, Cores=4 msg/s 56853 57509 56168 57195 56178 56317 56341 56680 58099 57590
pingpong.js QoS=1, Cores=4, Score='perc95' ms 41 41 41 41 41 41 41 41 41 41

@seriousme
Copy link
Contributor Author

A fix for the benchmarking is in #1046

Kind regards,
Hans

Copy link
Member

@robertsLando robertsLando left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this! ❤️

Let's see if @mcollina has some opinions on this otherwise this is good to merge 🙏🏼

@robertsLando robertsLando requested a review from Copilot September 3, 2025 09:47
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR reduces production dependencies by replacing 8 external packages with native Node.js APIs and local implementations. The goal is to minimize external dependencies while maintaining equivalent functionality.

  • Replaces external utilities like fastfall, fastparallel, fastseries with local implementations
  • Switches from hyperid and uuid packages to native randomUUID from Node.js crypto module
  • Replaces end-of-stream, retimer, and reusify with native or local alternatives

Reviewed Changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
package.json Removes 8 production dependencies
lib/utils.js Adds local ObjectPool and retimer implementations
lib/handlers/unsubscribe.js Replaces fastparallel with Promise.all pattern
lib/handlers/subscribe.js Adds runFall implementation and replaces fastparallel with Promise.all
lib/handlers/connect.js Switches from hyperid to native randomUUID
lib/client.js Replaces end-of-stream with native finished from stream
docs/Client.md Updates documentation to reflect hyperid → randomUUID change
aedes.js Replaces multiple external dependencies with native/local implementations

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Copy link
Collaborator

@mcollina mcollina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generically don't see a problem with dependencies and number of things on disk, but some of the changes are incorrect or will worsen the performance.

Some of it is in the right direction because it will now be using things from the platform.

done()
} catch (err) {
done(err)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is by far not equivalent. It also creates a bazillion of promises and it will significantly impact performance

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, the current solution is rather inefficient.
I'm working on a more elegant solution for this one.

aedes.js Outdated
this._series = series()
this._enqueuers = reusify(DoEnqueues)
this._series = runSeries
this._enqueuers = new ObjectPool(DoEnqueues)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be interesting to verify if this is needed at all. My 2 cents is that we don't need this at all anymore and there is no perf gap in just allocating them all the time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fully agree, see OP

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just ran the benchmark on the commit that only removes the object pool. No material difference, still about 18% improvement over the main branch. See: https://github.com/moscajs/aedes/actions/runs/17486480074/attempts/1#summary-49666422333

aedes.js Outdated
else resolve()
})
})))
done()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
done()
process.nextTick(done)

otherwise error handling would be a disaster

}
next(null, arg)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please test this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean with this comment. The coverage report says that its the runFallall is called 36 times and all code of runfall is covered during tests. The anonymous inner function runs 164 times, the next() function runs 628 times. Do you want me to move it to lib/utils.js and test it standalone?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having some standalone tests would be better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests to test/utils.js

@seriousme
Copy link
Contributor Author

@robertsLando

  • The objectPool has been removed leading to no material performance difference HEAD is still approx 18% faster than MAIN
  • timer.refresh() has been applied (I didn't know it either) and in future iterations we might be able to remove the whole retimer from lib/utils and use timer.refresh directly
  • clearWills has been completely refactored. Imo it is now more clear to see what the code actually does. The only difference in behaviour between the version in MAIN and this version is that the pipeline() can create variable batch sizes depending on how fast wills were fetched from persistence and how fast they can be processed. In practice my guess is that the fetch from persistence will always be faster than the processing which would result in batch sizes of the highwatermark of the Writable. Hence my choice to use a fixed batch size to keep the logic simple.

Kind regards,
Hans

@seriousme seriousme requested a review from mcollina September 5, 2025 14:53
@seriousme
Copy link
Contributor Author

@robertsLando
I also added a test to test/wills.js to:

  • show that the new clearwills mechanism works with a lager number of wills
  • increase test coverage

@seriousme
Copy link
Contributor Author

@robertsLando
also updated handlers/subscribe.js:
as completeSubscribe () was no longer called with an 'err' parameter, the if (err) would never be called decreasing coverage.

The only file without 100% coverage is now client.js because of :

aedes/lib/client.js

Lines 377 to 379 in 9b76359

} else {
this.emit('error', new Error('Client queue limit reached'))
}

which we can't test and therefore should imo exclude from coverage testing.

And legacy support in:

aedes/lib/client.js

Lines 169 to 172 in 9b76359

// hack to clean up the write callbacks in case of error
const state = this.conn._writableState
const list = typeof state.getBuffer === 'function' ? state.getBuffer() : state.buffer
list.forEach(drainRequest)

state.buffer is already EOL since NodeJS 14,see https://nodejs.org/api/deprecations.html#DEP0003

The associated drain request:

aedes/lib/client.js

Lines 362 to 364 in 9b76359

function drainRequest (req) {
req.callback()
}

is never called according to coverage testing.

Calling nodejs private API's like this.conn._writableState.getBuffer() should not be required, so Iḿ really wondering whether we should not leave this to conn.destroy().

Kind regards,
Hans

aedes.js Outdated
for (const clientId of Object.keys(this.clients)) {
promises.push(closeClient(this.clients[clientId]))
}
Promise.all(promises).then(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is equivalent, in case one fails, the callback will not be invoked

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replaced .then() by .finally()

}
next(null, arg)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having some standalone tests would be better.

}
} catch (err) {
done(err)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has the same problem with promises identified elsewhere

}
} catch (err) {
done(err)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has the same problem with promises identified elsewhere

})
})))
if (restore) {
done()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that if this throws, it would call done twice

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why, can you please explain?

I tried the following:

const pOk = new Promise(resolve => {
    console.log('pOK will resolve')
    resolve()
    })

const pFail = new Promise((resolve,reject) => {
    console.log('pFail will reject')
    reject('pFail rejected')
    })

try {
    await Promise.all([pOk,pFail])
    console.log('all Promises resolved ok')
} catch (err){
    console.log('At least one promise failed, first error:', err)
}

This gives me:

pOK will resolve
pFail will reject
At least one promise failed, first error: pFail rejected

Btw: I reworked doSubscribe to make this part more easy to read.

// since it is a rare race condition we ignore it in coverage testing
return
}
/* c8 ignore stop */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this tested before?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I know, the coverage testing shows that this part is never reached and given the comments it looked like a rare race condition to me.

completeUnsubscribe.call(state)
} catch (err) {
completeUnsubscribe.call(state, err)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same problem

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same discussion as doSubscribe(), I also reworked doUnsubscribe to make the code more readable

}
})
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just remove the utility if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// if chunks is half full when the iterator ends
if (chunks.length > 0) {
yield chunks
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you benchmark the simple implementation against https://github.com/mcollina/hwp/blob/main/index.js? I have a feeling this might problematic (or maybe V8 just got way faster over the years).

Copy link
Contributor Author

@seriousme seriousme Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran a benchmark: (see https://github.com/seriousme/hwp/blob/benchmarks/benchmark/bench.js)
And the results were quite consistent:

test num runs items per run num batches estimated ideal time (ms) lapsed time (ms)
benchBatch 100 100 7 7000 7640
benchForEach 100 100 7 7000 7643
benchBatch 100 1000 63 63000 66515
benchForEach 100 1000 63 63000 66527

Estimated ideal time is based on the following:
100 items per test run means 7 batch runs per batch (Match.ceil(100/16)), each batch taking 10ms, so 70ms per test run, 100 test runs of 70 ms should run in about 7 seconds,

In reality on a light Chromebook each batch on average takes 10.5 ms and 100 or 1000 items per run hardly makes any difference. 0.5 ms overhead on such a machine sounds not too bad to me.

Hope this helps!

Kind regards,
Hans

export async function * batch (readable, fn, batchSize) {
let chunks = []
for await (const chunk of readable) {
chunks.push(fn(chunk))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there might be an issue with error handling here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added testing of batch() to test/utils.js

@seriousme
Copy link
Contributor Author

FYI: with all the changes the github review comments are a bit hard to track.
I think everything is in, if I missed anything please let me know.
I will try to benchmark batch() against https://github.com/mcollina/hwp/blob/main/index.js in the coming days.

@seriousme seriousme requested a review from mcollina September 9, 2025 17:39
@seriousme
Copy link
Contributor Author

@robertsLando @mcollina
Benchmark had been done, see #1045 (comment)
Can you please review again ?

Kind regards,
Hans

Copy link
Member

@robertsLando robertsLando left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would like to wait for @mcollina feedback on this. From my POV this is good to merge 👍🏼

@robertsLando
Copy link
Member

@mcollina ping

@seriousme
Copy link
Contributor Author

Just to be sure: you all are not waiting for me right?

@robertsLando
Copy link
Member

@seriousme nope, I'm waiting for @mcollina last feedback after your changes after his review :)

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.

3 participants