Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make cron validate x100 faster #306

Open
wants to merge 3 commits into
base: next
Choose a base branch
from

Conversation

albertodiazdorado
Copy link

This Pull Request addresses #81


Foreword

I believe that this Pull Request contains the right ideas to make cron-validate perform reasonably fast. However, I am not sure if my implementation aligns with your designs idea @Airfooox

Thus, please let me know if you disagree with the implementation and we can try to find a better means to integrate the performance improvements into the library.

Description

This Pull Request introduces two small optimizations that makes cron-validate x100 faster when called in a loop. The changes are:

  1. Load built-in presets on module load (i.e. "when you import options") instead of when calling validateOptions. Since the build-in presents do not change, we can load them just once instead of loading them every time that we use cron()
  2. Introduce a caching mechanism for validated options. If the users invokes cron() with the same settings multiple time (defined by preset/presetId and overrides), then we would spend quite a lot of time creating and validating an Options object. The creation & validation of this object makes the library about x100 faster when called in a loop.

Benchmarking

I used this script for benchmarking, using some cron expressions akin to the ones I have been dealing with in production:

import { performance, PerformanceObserver } from 'perf_hooks'
import cronImport from './lib/index.js'

const cron = cronImport.default;

// Helper functions to calculate statistics
function calculateMean(durations) {
  const sum = durations.reduce((a, b) => a + b, 0)
  return sum / durations.length
}

function calculateMedian(durations) {
  const sorted = [...durations].sort((a, b) => a - b)
  const mid = Math.floor(sorted.length / 2)
  return sorted.length % 2 !== 0
    ? sorted[mid]
    : (sorted[mid - 1] + sorted[mid]) / 2
}

function calculateStdDev(durations) {
  const mean = calculateMean(durations)
  const variance =
    durations.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) /
    durations.length
  return Math.sqrt(variance)
}

// Set up the PerformanceObserver to log performance entries
const obs = new PerformanceObserver(list => {
  const durationsInMicroseconds = performance.getEntriesByType('measure').map(entry => entry .duration * 1_000)

  const mean = calculateMean(durationsInMicroseconds);
  const median = calculateMedian(durationsInMicroseconds);
  const stddev = calculateStdDev(durationsInMicroseconds);

  console.table({
    Samples: durationsInMicroseconds.length,
    Mean: `${mean.toFixed(2)} µs`,
    Median: `${median.toFixed(2)} µs`,
    StdDev: `${stddev.toFixed(2)} µs`,
  });
})

obs.observe({ entryTypes: ['measure'], buffered: true })

const cronExpressions = [
  "0 9 * * MON-FRI",
  "0 22 * * SAT-SUN",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SUN",
  "0 1 * * TUE",
  "0 6 * * MON-FRI",
  "0 20 * * MON-SAT",
  "0 8 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 6 * * SAT-SUN",
  "0 1 * * SAT-SUN",
  "0 6 * * MON-FRI",
  "0 20 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 20 * * MON-FRI",
  "0 6 * * SAT-SUN",
  "0 1 * * SAT-SUN",
  "0 6 * * MON-FRI",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 6 * * *",
  "0 0 * * *",
  "0 20 * * MON-FRI",
  "0 6 * * SAT-SUN",
  "0 1 * * SAT-SUN",
  "0 6 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 0 * * *",
  "0 6 * * *",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 20 * * MON-SAT",
  "0 7 * * MON-SAT",
  "0 5 * * MON-FRI",
  "0 23 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * SUN",
  "0 0 * * MON",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 8 * * *",
  "0 20 * * *",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 7 * * *",
  "0 0 * * *",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 8 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 18 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 22 * * SAT-SUN",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SUN",
  "0 1 * * TUE",
  "0 6 * * MON-FRI",
  "0 20 * * MON-SAT",
  "0 8 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 6 * * SAT-SUN",
  "0 1 * * SAT-SUN",
  "0 6 * * MON-FRI",
  "0 20 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 20 * * MON-FRI",
  "0 6 * * SAT-SUN",
  "0 1 * * SAT-SUN",
  "0 6 * * MON-FRI",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 6 * * *",
  "0 0 * * *",
  "0 20 * * MON-FRI",
  "0 6 * * SAT-SUN",
  "0 1 * * SAT-SUN",
  "0 6 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 0 * * *",
  "0 6 * * *",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 20 * * MON-SAT",
  "0 7 * * MON-SAT",
  "0 5 * * MON-FRI",
  "0 23 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 19 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * SUN",
  "0 0 * * MON",
  "0 19 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 8 * * *",
  "0 20 * * *",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 7 * * *",
  "0 0 * * *",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 7 * * *",
  "0 0 * * *",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "50 1 * * SAT",
  "0 1 * * MON",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 8 * * MON-FRI",
  "0 22 * * MON-FRI",
  "0 7 * * MON-FRI",
  "0 18 * * MON-FRI",
  "0 6 * * *",
  "0 0 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 0 * * *",
  "0 6 * * *",
  "0 18 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 6 * * MON-FRI",
  "0 19 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 22 * * SAT-SUN",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 9 * * MON-FRI",
  "0 22 * * SAT-SUN",
]

function validateCronExpressions() {
  for (const expr of cronExpressions) {
    performance.mark('start')
    const result = cron(expr, {
      preset: 'default',
      override: { useAliases: true },
    })
    performance.mark('end')

    if (result.isError()) {
      throw Error('is error!')
    }
    performance.measure('TOTAL TIME', 'start', 'end')
  }
}

validateCronExpressions()

To execute the benchmark, first you need to delete the line "type":"module" from package.json. The output produced by the compiler is CJS, so we cannot import it successfully if we mark the package as ESM. Then, execute the following:

git checkout $branch
npm run build
node profile.mjs

On my machine, these are the results for next:

Metric Value
Samples 400
Mean 1522.21µs
Median 1316.96 µs
StdDev 670.58 µs

These are the results for make-cron-validate-x100-faster:

Metric Value
Samples 400
Mean 26.97µs
Median 12.67 µs
StdDev 215.02 µs

this commit is a preparation of the introduction of a cache mechanism to speed
up the execution of cron-validate. Although the commit looks irrelevant if
considered in isolation, it greatly simplify the analysis of the next commit.

I also removed unused imports from index.ts

Signed-off-by: albertodiazdorado <[email protected]>
In the previous source code version we would load the built-in presets on every
invocation of 'cron' (via 'validateOptions'). This means that we do a lot of
repeated work on consecutive invocations of 'cron'. With the new design we load
the presets just once when we load the module 'options.ts', via the new function
'loadPresets()'.

In order to avoid circular dependencies, I modified 'presets.ts' to export
just vanilla JavaScript objects.

Signed-off-by: albertodiazdorado <[email protected]>
cron-validated was very slow when called in a loop. The reason is that we
repeated all these operations in every call:
1. Merge the preset and the overrides into a so-called 'unvalidatedConfig'
2. Dynamically generate a yup validation schema from the preset and the override
3. Validate the 'unvalidatedConfig' using the dynamically generated schema

Step 2 is particularly slow, but steps 1 and 3 aren't particularly fast either.
Luckily for us, all these 3 steps are perfectly cacheable. This Pull Request
introduces a so-called 'optionsCache' to avoid repeating the 3 steps if we
already executed them in the past (which happens all the time in a loop).

The cache is a map from 'cache keys' to 'validated options'. The cache key
is the concatenarion of 'presetId' and a stringified 'override' object. If two
function calls produce the same cache key, it invariably means that the user
wants to use the same 'Options' in both function calls. This is how the cache
produces a performance improvement of about x100 for consecutive calls.

Note that the construction of the cache key is not optimized. If the user uses
'cron-validate' two consecutive times with identical 'override' objects, but
swaps the order of the override object fields, then they will not benefit from
caching. This can be fixed by using deterministic stringification, but I do not
consider it necessary.

Signed-off-by: albertodiazdorado <[email protected]>
@albertodiazdorado
Copy link
Author

Hi @Airfooox , do you think you could have a look into this?

@Airfooox
Copy link
Owner

Airfooox commented Feb 2, 2025

Yea, I'll take a look at this this week or the next.

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.

2 participants