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

Add coldstart simulator #725

Merged
merged 1 commit into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
.DS_Store
__pycache__
!test/mock/dep-warn/**/node_modules
.DS_Store
.nyc_output/
bin/*.json
bin/sandbox-binary*
chonky.txt
coverage/
node_modules/
package-lock.json
scratch/
test/mock/*/.db
test/mock/*/src/*/*/vendor/
test/mock/*/*.test
test/mock/*/src/*/*/vendor/
test/mock/*/tmp
test/mock/tmp

Expand Down
9 changes: 9 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

---

## [5.9.0] 2023-10-24

### Added

- Add coldstart simulator via `@sandbox coldstart true` setting in `prefs.arc`
- Note: Windows users must install [`du`](https://learn.microsoft.com/en-us/sysinternals/downloads/du)

---

## [5.8.5] 2023-10-24

### Fixed
Expand Down
23 changes: 21 additions & 2 deletions src/invoke-lambda/exec/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
let _asap = require('@architect/asap')
let load = require('./loader')
let spawn = require('./spawn')
let { runtimeEval } = require('../../lib')
let { getFolderSize, runtimeEval } = require('../../lib')
let { invocations } = require('../../arc/_runtime-api')

let tenMB = 1024 * 1024 * 10
function coldstartMs (bytes) {
// Very rough coldstart estimate: ~10MB = 100ms
return Math.floor(bytes / (tenMB / 100))
}

module.exports = function exec (lambda, params, callback) {
// ASAP is a special case that doesn't spawn
if (lambda.arcStaticAssetProxy) {
Expand Down Expand Up @@ -34,7 +40,20 @@ module.exports = function exec (lambda, params, callback) {
let bootstrap = load()[run]
var { command, args } = runtimeEval[run](bootstrap)
}
spawn({ command, args, ...params, lambda }, callback)
if (params.coldstart) {
getFolderSize(lambda.src, (err, folderSize) => {
if (err) callback(err)
else {
let { requestID } = params
let coldstart = coldstartMs(folderSize)
params.update.verbose.status(`[${requestID}] Coldstart simulator: ${coldstart}ms latency added to ${folderSize}b Lambda`)
spawn({ command, args, ...params, coldstart, lambda }, callback)
}
})
}
else {
spawn({ command, args, ...params, lambda }, callback)
}
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/invoke-lambda/exec/runtimes/deno.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ const headers = { 'content-type': 'application/json; charset=utf-8' };
}
catch (err) {
(async function initError () {
console.log('Lambda init error:', err);
const unknown = 'Unknown init error';
console.log('Lambda init error:', err || unknown);
const initErrorEndpoint = url('init/error');
const errorMessage = err.message || 'Unknown init error';
const errorMessage = err.message || unknown;
const errorType = err.name || 'Unknown init error type';
const stackTrace = err.stack ? err.stack.split('\n') : undefined;
const body = JSON.stringify({ errorMessage, errorType, stackTrace });
Expand Down
5 changes: 3 additions & 2 deletions src/invoke-lambda/exec/runtimes/node-esm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ function client (method, params) {
}
catch (err) {
(async function initError () {
console.log('Lambda init error:', err.body || err.message);
let unknown = 'Unknown init error';
console.log('Lambda init error:', err.body || err.message || unknown);
let initErrorEndpoint = url('init/error');
let errorMessage = err.message || 'Unknown init error';
let errorMessage = err.message || unknown;
let errorType = err.name || 'Unknown init error type';
let stackTrace = err.stack ? err.stack.split('\n') : undefined;
let body = { errorMessage, errorType, stackTrace };
Expand Down
5 changes: 3 additions & 2 deletions src/invoke-lambda/exec/runtimes/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,10 @@ function client (method, params) {
catch (err) {
/* eslint-disable-next-line */
(async function initError () {
console.log('Lambda init error:', err.body || err.message);
let unknown = 'Unknown init error';
console.log('Lambda init error:', err.body || err.message || unknown);
let initErrorEndpoint = url('init/error');
let errorMessage = err.message || 'Unknown init error';
let errorMessage = err.message || unknown;
let errorType = err.name || 'Unknown init error type';
let stackTrace = err.stack ? err.stack.split('\n') : undefined;
let body = { errorMessage, errorType, stackTrace };
Expand Down
45 changes: 27 additions & 18 deletions src/invoke-lambda/exec/spawn.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,37 @@ let errors = require('../../lib/errors')
let { invocations } = require('../../arc/_runtime-api')

module.exports = function spawnChild (params, callback) {
let { args, context, command, lambda, options, requestID, timeout } = params
let { args, coldstart, context, command, lambda, options, requestID, timeout } = params
let { apiType, update } = context
let isInLambda = process.env.AWS_LAMBDA_FUNCTION_NAME
let timedOut = false

// Let's go!
let child = spawn(command, args, options)
let pid = child.pid
let error
let closed

child.stdout.on('data', data => process.stdout.write('\n' + data))
child.stderr.on('data', data => process.stderr.write('\n' + data))
child.on('error', err => {
error = err
// Seen some non-string oob errors come via binary compilation
if (err.code) shutdown('error')
})
child.on('close', (code, signal) => {
update.debug.status(`[${requestID}] Emitted 'close' (pid ${pid}, code '${code}', signal '${signal}')`)
shutdown('child process closure')
})
let pid = 'init'
let child, error, closed
function start () {
child = spawn(command, args, options)
pid = child.pid

child.stdout.on('data', data => process.stdout.write('\n' + data))
child.stderr.on('data', data => process.stderr.write('\n' + data))
child.on('error', err => {
error = err
// Seen some non-string oob errors come via binary compilation
if (err.code) shutdown('error')
})
child.on('close', (code, signal) => {
update.debug.status(`[${requestID}] Emitted 'close' (pid ${pid}, code '${code}', signal '${signal}')`)
shutdown('child process closure')
})
}
if (coldstart) {
if (coldstart < timeout) setTimeout(start, coldstart)
else {
update.debug.status(`[${requestID}] Coldstart simulator: coldstart of ${coldstart}ms exceeds timeout of ${timeout}ms, not spawning the Lambda`)
}
}
else start()

// Set an execution timeout
let to = setTimeout(function () {
Expand Down Expand Up @@ -63,7 +72,7 @@ module.exports = function spawnChild (params, callback) {
let isRunning = true
try {
// Signal 0 is a special node construct, see: https://nodejs.org/docs/latest-v14.x/api/process.html#process_process_kill_pid_signal
isRunning = process.kill(pid, 0)
isRunning = pid === 'init' ? false : process.kill(pid, 0)
}
catch (err) {
isRunning = err.code === 'EPERM'
Expand Down
3 changes: 3 additions & 0 deletions src/invoke-lambda/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ module.exports = function invokeLambda (params, callback) {
update.debug.raw(output + '...')
if (chonky) update.debug.status(`[${requestID}] Truncated event payload log at 10KB`)

let coldstart = inventory.inv._project?.preferences?.sandbox?.coldstart || false

invocations[requestID] = {
request: event,
lambda,
Expand All @@ -58,6 +60,7 @@ module.exports = function invokeLambda (params, callback) {
},
requestID,
timeout: config.timeout * 1000,
coldstart,
update,
}, function done (err) {
update.debug.status(`[${requestID}] Final invocation state`)
Expand Down
37 changes: 37 additions & 0 deletions src/lib/get-folder-size.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
let { exec } = require('child_process')

// Adapted with gratitude from https://github.com/simoneb/fast-folder-size under the AWISC license
let commands = {
win32: `du${process.arch === 'x64' ? '64' : ''}.exe -nobanner -accepteula -q -c .`,
darwin: `du -sk .`,
linux: `du -sb .`,
}
let processOutput = {
win32 (stdout) {
// query stats indexes from the end since path can contain commas as well
const stats = stdout.split('\n')[1].split(',')
const bytes = +stats.slice(-2)[0]
return bytes
},
darwin (stdout) {
const match = /^(\d+)/.exec(stdout)
const bytes = Number(match[1]) * 1024
return bytes
},
linux (stdout) {
const match = /^(\d+)/.exec(stdout)
const bytes = Number(match[1])
return bytes
},
}

module.exports = function getFolderSize (cwd, callback) {
let sys = process.platform
if (!Object.keys(commands).includes(sys)) {
return callback(Error('Coldstart testing only supported on Linux, Mac, and Windows'))
}
exec(commands[sys], { cwd }, (err, stdout) => {
if (err) callback(err)
else callback(null, processOutput[sys](stdout))
})
}
2 changes: 2 additions & 0 deletions src/lib/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
let getFolderSize = require('./get-folder-size')
let makeRequestId = require('./request-id')
let runtimeEval = require('./runtime-eval')
let template = require('./template')
let userEnvVars = require('./user-env-vars')

module.exports = {
getFolderSize,
makeRequestId,
runtimeEval,
template,
Expand Down
13 changes: 12 additions & 1 deletion src/sandbox/maybe-hydrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ module.exports = function maybeHydrate ({ cwd, inventory, quiet, deleteVendor },
callback()
}
else {
let coldstart = inv._project?.preferences?.sandbox?.coldstart || false

// Enable vendor dir deletion by default
let del = deleteVendor === undefined ? true : deleteVendor
if (inv._project.preferences?.sandbox?.['delete-vendor'] !== undefined) {
Expand Down Expand Up @@ -90,8 +92,11 @@ module.exports = function maybeHydrate ({ cwd, inventory, quiet, deleteVendor },
// Looks like deps are all good here, no need to destroy `node_modules`
else callback()
}
else {
else if (coldstart) {
destroy(nodeModules)
install(callback)
}
else {
callback()
}
}
Expand All @@ -110,6 +115,9 @@ module.exports = function maybeHydrate ({ cwd, inventory, quiet, deleteVendor },
}
install(callback)
}
else if (coldstart) {
install(callback)
}
else callback()
}

Expand All @@ -121,6 +129,9 @@ module.exports = function maybeHydrate ({ cwd, inventory, quiet, deleteVendor },
if (exists(gemfile)) {
install(callback)
}
else if (coldstart) {
install(callback)
}
else callback()
}

Expand Down
53 changes: 53 additions & 0 deletions test/integration/http/misc-http-test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
let { join } = require('path')
let { existsSync, statSync, writeFileSync } = require('fs')
let tiny = require('tiny-json-http')
let test = require('tape')
let sut = join(process.cwd(), 'src')
Expand Down Expand Up @@ -289,4 +290,56 @@ function runTests (runType, t) {
t.test(`[Misc / ${runType}] Shut down Sandbox`, t => {
shutdown[runType](t)
})

// Windows doesn't ship with `du`, so we'll just assume the vendored code runs properly
let isWin = process.platform.startsWith('win')
if (!isWin) {
t.test(`[Misc / ${runType}] Start Sandbox`, t => {
startup[runType](t, 'coldstart')
})

t.test(`[Misc / ${runType}] Set up coldstart chonkyboi`, t => {
t.plan(1)
let file = join(process.cwd(), 'test', 'mock', 'coldstart', 'src', 'http', 'get-chonk', 'chonky.txt')
let MB = 1024 * 1024
let size = MB * 115
if (existsSync(file)) t.equal(statSync(file).size, size, 'Found coldstart enchonkinator')
else {
let start = Date.now()
writeFileSync(file, Buffer.alloc(size))
console.log(`Wrote enchonkinator to Lambda in ${Date.now() - start}ms`)
t.equal(statSync(file).size, size, 'Found coldstart enchonkinator')
}
})

t.test(`[Misc / ${runType}] No coldstart timeout`, t => {
t.plan(1)
tiny.get({
url: url + '/smol'
}, function _got (err, result) {
if (err) t.end(err)
else t.deepEqual(result.body, { ok: true }, 'Lambda did not timeout from a coldstart')
})
})

t.test(`[Misc / ${runType}] Coldstart timeout`, t => {
t.plan(3)
tiny.get({
url: url + '/chonk'
}, function _got (err, result) {
if (err) {
let message = 'Timeout error'
let time = '1 second'
t.equal(err.statusCode, 500, 'Errors with 500')
t.match(err.body, new RegExp(message), `Errors with message: '${message}'`)
t.match(err.body, new RegExp(time), `Timed out set to ${time}`)
}
else t.end(result)
})
})

t.test(`[Misc / ${runType}] Shut down Sandbox`, t => {
shutdown[runType](t)
})
}
}
9 changes: 9 additions & 0 deletions test/mock/coldstart/app.arc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@app
mockapp

@aws
timeout 1

@http
get /smol
get /chonk
2 changes: 2 additions & 0 deletions test/mock/coldstart/prefs.arc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@sandbox
coldstart true
1 change: 1 addition & 0 deletions test/mock/coldstart/src/http/get-chonk/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export let handler = async () => ({ ok: true })
1 change: 1 addition & 0 deletions test/mock/coldstart/src/http/get-smol/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export let handler = async () => ({ ok: true })
Loading