Implement throttler for non-core db functions to improve postgres performance - #318
Conversation
JonoPrest
commented
Nov 4, 2024
- Adds debounce module to the envio package
- Moves Pino bindings and Utils to envio package
- Adds rescript-schema to envio package
- Uses debounce instead of LazyWriter for Benchmark.res
- Uses debounce instead of async task queue for update chain metadata
- Applies debounce to cleanup db functions
- Adds tests for debounce module
DZakh
left a comment
There was a problem hiding this comment.
I didn't take at the Debouncer code yet. Just a few early comments, I'll continue in 30 minutes
| "dependencies": { | ||
| "rescript": "11.1.3" | ||
| "rescript": "11.1.3", | ||
| "rescript-schema": "8.2.0" |
There was a problem hiding this comment.
We should keep the package.json.tmpl dependencies in sync. Need to add rescript-schema there as well
| let t = (logger, exn, message) => | ||
| logger->Pino.errorExn(message->Pino.createPinoMessageWithError(exn)) | ||
|
|
||
| %%private( |
There was a problem hiding this comment.
Can you elaborate on why you think so?
IMO this is better for the given case unless we want opaque types (I'd prefer visible types to make it easier to test). This way you don't need to maintain 2 files and keep signatures/types matching.
There was a problem hiding this comment.
Ok, I thought we want it to be opaque
There was a problem hiding this comment.
I like opaque types from an API perspective, but it makes it less testable and more encapsulated. I'm keen to try not use too much encapsulation unless it solves footguns. At which point you can just have an inner module of exposed getter functions for testing.
There was a problem hiding this comment.
I prefer the black-box approach for unit testing, when we test only the public API the way how the user would interact with it. This is more reliable since we test exactly how the code is used. Also, when some internal implementation changes, it doesn't require us to update the tests.
There was a problem hiding this comment.
Fair, but it's still more boilerplate just to hide simple data etc. I'm wondering how much the black-box ceremony helps us vs just creates overhead.
There was a problem hiding this comment.
I got used to writing the code this way, so I think this is not a big difference. But might be annoying sometimes
| let t = (logger, exn, message) => | ||
| logger->Pino.errorExn(message->Pino.createPinoMessageWithError(exn)) |
There was a problem hiding this comment.
Nope, that was accidentally left in 👍🏼
DZakh
left a comment
There was a problem hiding this comment.
Looks good. But this is more like a throttler than a debouncer. Debounce restarts the timer every time a new function is scheduled, while the throttle keeps the original timer
| Assert.deepEqual( | ||
| actionsCalled, | ||
| [1, 2], | ||
| ~message="Scheduler should have been called straight after the initial interval", |
There was a problem hiding this comment.
This is Throttler then, not a Debouncer
There was a problem hiding this comment.
Kind of but if you look at the first test it also has debouncing properties. Open to renaming but this is the behaviour I was looking for.
There was a problem hiding this comment.
Maybe Debottler? 🙃
There was a problem hiding this comment.
Jk, I think we can call it Throttler, it makes more sense 👍🏼
| if timeSinceLastRun >= debouncer.intervalMillis { | ||
| debouncer.isRunning = true | ||
| debouncer.scheduled = None | ||
| debouncer.lastRunTimeMillis = Js.Date.now() |
There was a problem hiding this comment.
Should we maybe set it after the fn call?
There was a problem hiding this comment.
The way I see it, it's not a cooldown period so much as an interval, it can work either way it just makes sense in my head to have a time interval from when the last function was called, not necessarily finished.
Say a cleanup function gets called on the postgres and takes really long awaiting a transaction. By the time it's finished we can immediately execute the next scheduled function if it's passed the interval.
| await Js.Promise2.make((~resolve, ~reject as _) => { | ||
| let _ = Js.Global.setTimeout(() => { | ||
| debouncer.isAwaitingInterval = false | ||
| resolve() | ||
| }, Belt.Int.fromFloat(timeOutInterval)) | ||
| }) | ||
|
|
||
| await debouncer->startInternal |
There was a problem hiding this comment.
I'd say we don't need a promise here. You can call debouncer->startInternal directly from Js.Global.setTimeout
There was a problem hiding this comment.
True! It's just then that the resolve of startInternal is not indicative of it actually finishing.
| let logger = Logging.createChild( | ||
| ~params={ | ||
| "context": "Debouncer for chain metadata writes", | ||
| "intervalMillis": intervalMillis, | ||
| }, | ||
| ) | ||
| Debouncer.make(~intervalMillis, ~logger) |
There was a problem hiding this comment.
I think this is convenient here, but isn't it more correct to pass the logger from where we call schedule, instead of when we create the Debouncer
There was a problem hiding this comment.
It can work either way, then we just store the logger here in this state and pass where it get's called. I guess it's just a preference of where we store the logger. I think it's better than constructing a new logger on every call unless we want to pass in different context values, which might be nice as well 🤔
There was a problem hiding this comment.
Actually after looking back at the code, it is best this way.
startInternal will recurse through scheduled calls, so if that takes the logger as a param then it can only hand down the same logger even if it's a different scheduled call.
If you actually want a logger to be tied to where it gets scheduled then the "scheduled" state should also have a logger. I think we can do that but it's not needed yet.
96282e3 to
401dfa6
Compare