Sybren A. Stüvel e48fa4cc5f Manager: load job compiler scripts on demand, instead of at startup
The Manager now loads the JavaScript files for job types on demand,
instead of caching them in memory at startup.

This will make certain calls a bit less performant, but in practice this
is around the order of a millisecond so it shouldn't matter much.

Fixes: #104349
2025-02-10 12:07:55 +01:00

175 lines
4.3 KiB
Go

package job_compilers
// SPDX-License-Identifier: GPL-3.0-or-later
import (
"fmt"
"io"
"io/fs"
"path"
"strings"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/require"
"github.com/rs/zerolog/log"
)
// loadScripts loads all Job Compiler JavaScript files and returns them.
func loadScripts() map[string]Compiler {
scripts := map[string]Compiler{}
// Collect all job compilers.
for _, fs := range getAvailableFilesystems() {
compilersfromFS, err := loadScriptsFrom(fs)
if err != nil {
log.Error().Err(err).Interface("fs", fs).Msg("job compiler: error loading scripts")
continue
}
if len(compilersfromFS) == 0 {
continue
}
log.Debug().Interface("fs", fs).
Int("numScripts", len(compilersfromFS)).
Msg("job compiler: found job compiler scripts")
// Merge the returned compilers into the big map, skipping ones that were
// already there.
for name := range compilersfromFS {
_, found := scripts[name]
if found {
continue
}
scripts[name] = compilersfromFS[name]
}
}
return scripts
}
// loadScriptsFrom iterates over files in the root of the given filesystem,
// compiles the files, and returns the "name -> compiler" mapping.
func loadScriptsFrom(filesystem fs.FS) (map[string]Compiler, error) {
dirEntries, err := fs.ReadDir(filesystem, ".")
if err != nil {
return nil, fmt.Errorf("failed to find scripts in %v: %w", filesystem, err)
}
compilers := map[string]Compiler{}
for _, dirEntry := range dirEntries {
if !dirEntry.Type().IsRegular() {
continue
}
filename := dirEntry.Name()
if !strings.HasSuffix(filename, ".js") {
continue
}
script_bytes, err := loadFileFromFS(filesystem, filename)
if err != nil {
log.Error().Err(err).Str("filename", filename).Msg("failed to read script")
continue
}
if len(script_bytes) < 8 {
log.Debug().
Str("script", filename).
Int("fileSizeBytes", len(script_bytes)).
Msg("ignoring tiny JS file, it is unlikely to be a job compiler script")
continue
}
program, err := goja.Compile(filename, string(script_bytes), true)
if err != nil {
log.Error().Err(err).Str("filename", filename).Msg("failed to compile script")
continue
}
jobTypeName := filenameToJobType(filename)
compilers[jobTypeName] = Compiler{
jobType: jobTypeName,
program: program,
filename: filename,
}
log.Debug().
Str("script", filename).
Str("jobType", jobTypeName).
Msg("job compiler: loaded script")
}
return compilers, nil
}
func loadFileFromFS(filesystem fs.FS, path string) ([]byte, error) {
file, err := filesystem.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open file %s on filesystem %s: %w", path, filesystem, err)
}
return io.ReadAll(file)
}
func filenameToJobType(filename string) string {
extension := path.Ext(filename)
stem := filename[:len(filename)-len(extension)]
return strings.ReplaceAll(stem, "_", "-")
}
func newGojaVM(registry *require.Registry) *goja.Runtime {
vm := goja.New()
vm.SetFieldNameMapper(goja.UncapFieldNameMapper())
mustSet := func(name string, value interface{}) {
err := vm.Set(name, value)
if err != nil {
log.Panic().Err(err).Msgf("unable to register '%s' in Goja VM", name)
}
}
// Set some global functions.
mustSet("print", jsPrint)
mustSet("alert", jsAlert)
mustSet("frameChunker", jsFrameChunker)
mustSet("formatTimestampLocal", jsFormatTimestampLocal)
mustSet("shellSplit", func(cliArgs string) []string {
return jsShellSplit(vm, cliArgs)
})
// Pre-import some useful modules.
registry.Enable(vm)
mustSet("author", require.Require(vm, "author"))
mustSet("path", require.Require(vm, "path"))
mustSet("process", require.Require(vm, "process"))
return vm
}
// compilerVMForJobType returns a Goja *Runtime that has the job compiler script
// for the given job type loaded up.
func (s *Service) compilerVMForJobType(jobTypeName string) (*VM, error) {
// TODO: only load the one necessary script, and not everything.
scripts := loadScripts()
script, ok := scripts[jobTypeName]
if !ok {
return nil, ErrJobTypeUnknown
}
runtime := newGojaVM(s.registry)
if _, err := runtime.RunProgram(script.program); err != nil {
return nil, err
}
vm := &VM{
runtime: runtime,
compiler: script,
}
if err := vm.updateEtag(); err != nil {
return nil, err
}
return vm, nil
}