Skip to content
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
3 changes: 2 additions & 1 deletion packages/config-resolver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"README.md"
],
"scripts": {
"build": "tsc -b"
"build": "tsc -b",
"test": "tsx --test test/index.test.ts"
}
}
13 changes: 9 additions & 4 deletions packages/config-resolver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,16 @@ export async function resolveExpressions (
return Promise.all(obj.map((item, i) => resolveExpressions(item, `${path}[${i}]`)))
}
if (obj !== null && typeof obj === 'object') {
const entries = Object.entries(obj).filter(([key]) => key !== '__proto__' && key !== 'constructor')
const resolved = await Promise.all(
entries.map(([key, value]) => {
const fieldPath = path ? `${path}.${key}` : key
return resolveExpressions(value, fieldPath)
})
)
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
if (key === '__proto__' || key === 'constructor') continue
const fieldPath = path ? `${path}.${key}` : key
result[key] = await resolveExpressions(value, fieldPath)
for (let i = 0; i < entries.length; i++) {
result[entries[i]![0]] = resolved[i]
}
return result
}
Expand Down
24 changes: 24 additions & 0 deletions packages/config-resolver/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,30 @@ describe('resolveExpressions', () => {
}
)
})

it('resolves object values concurrently, not sequentially', async () => {
registerResolver('slow', async () => {
await new Promise(resolve => setTimeout(resolve, 50))
return 'done'
})

// Calibrate: measure average single-resolver run over 10 iterations
let totalSingle = 0
for (let i = 0; i < 10; i++) {
const s = performance.now()
await resolveExpressions({ x: '$(slow:1)' })
totalSingle += performance.now() - s
}
const avgSingle = totalSingle / 10

// Now resolve 3 concurrently -- should take ~1x avgSingle, not 3x
const start = performance.now()
const result = await resolveExpressions({ a: '$(slow:1)', b: '$(slow:2)', c: '$(slow:3)' })
const elapsed = performance.now() - start
assert.deepEqual(result, { a: 'done', b: 'done', c: 'done' })
const limit = avgSingle * 1.5
assert.ok(elapsed < limit, `expected parallel <${limit.toFixed(0)}ms (1.5x avg single), took ${elapsed.toFixed(0)}ms`)
})
})

// ---------------------------------------------------------------------------
Expand Down
86 changes: 35 additions & 51 deletions scripts/perf-check
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// PERF_WARMUP=5 PERF_RUNS=30 scripts/perf-check

import { execFileSync } from "node:child_process"
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
Expand All @@ -30,84 +30,68 @@ const MIN_RUNS = Number(process.env.PERF_RUNS ?? 15)
// Measured with: hyperfine -w 10 -m 100 <command>
// Hardware: Lenovo ThinkPad X1 Carbon, i7-1365U, 16 GB RAM, Arch Linux
const BASELINES_MS = {
"elastic": 99.0,
"elastic --help": 99.4,
"elastic es --help": 93.3,
"elastic es": 54.0,
"elastic cloud --help": 59.4,
"elastic": 51.1,
"elastic --help": 48.5,
"elastic es --help": 59.0,
"elastic es": 58.2,
"elastic cloud --help": 62.3,
"elastic cloud": 42.4,
"elastic kb --help": 83.7,
"elastic kb": 76.9,
"elastic es indices --help": 102.4,
"elastic es indices update-aliases --help": 273.0,
}



const THRESHOLD = 1.75 // fail if mean performance is 75% slower than baseline

// build the list of full commands to benchmark, one entry per baseline key
const commands = Object.keys(BASELINES_MS).map((label) => {
const args = label.slice("elastic".length).trim()
return args.length > 0 ? `node ${CLI} ${args}` : `node ${CLI}`
})

const tmpDir = mkdtempSync(join(tmpdir(), "elastic-cli-bench-"))
const jsonOut = join(tmpDir, "results.json")

try {
execFileSync(
"hyperfine",
[
"--warmup", String(WARMUP),
"--min-runs", String(MIN_RUNS),
"--export-json", jsonOut,
"--shell=none",
...commands,
],
{ stdio: "inherit" },
)

const raw = JSON.parse(readFileSync(jsonOut, "utf8"))

// hyperfine stores mean in seconds; convert to ms
// normalise the command string back to a BASELINES_MS key by stripping the
// absolute path prefix and collapsing any extra whitespace
const results = raw.results.map((r) => ({
command: r.command
.replace(/^node\s+.*?dist[/\\]cli\.js/, "elastic")
.replace(/\s+/g, " ")
.trim(),
meanMs: r.mean * 1000,
}))
// Use a minimal static config so benchmarks measure CLI startup, not secret-store latency.
const staticConfig = join(tmpDir, ".elasticrc.yml")
writeFileSync(staticConfig, [
'current_context: bench',
'contexts:',
' bench:',
' elasticsearch:',
' url: http://localhost:9200',
' auth:',
' api_key: static-key',
'',
].join('\n'))

let failed = false

for (const { command, meanMs } of results) {
const baseline = BASELINES_MS[command]
if (baseline == null) {
console.warn(`⚠ No baseline for "${command}" — skipping`)
continue
}
for (const [label, baseline] of Object.entries(BASELINES_MS)) {
const args = label.slice("elastic".length).trim()
const cmd = args.length > 0 ? `node ${CLI} ${args}` : `node ${CLI}`
const jsonOut = join(tmpDir, `result-${encodeURIComponent(label)}.json`)

execFileSync(
"hyperfine",
["--warmup", String(WARMUP), "--min-runs", String(MIN_RUNS), "--export-json", jsonOut, "--shell=none", cmd],
{ stdio: "inherit", env: { ...process.env, ELASTIC_CLI_CONFIG_FILE: staticConfig } },
)

const meanMs = JSON.parse(readFileSync(jsonOut, "utf8")).results[0].mean * 1000
const limit = baseline * THRESHOLD
const pct = ((meanMs / baseline - 1) * 100).toFixed(1)
const sign = meanMs > baseline ? "+" : ""
const ok = meanMs <= limit
const icon = ok ? "✓" : "✗"
console.log(
`${icon} ${command.padEnd(24)} mean ${meanMs.toFixed(1)} ms ` +
`${ok ? "✓" : "✗"} ${label.padEnd(24)} mean ${meanMs.toFixed(1)} ms ` +
`(baseline ${baseline} ms, ${sign}${pct}%, limit ${limit.toFixed(1)} ms)`,
)
if (!ok) failed = true
}

if (failed) {
console.error(
"\nPerformance regression detected. " +
"One or more commands exceeded their baseline by more than 20%.",
)
console.error("\nPerformance regression detected. One or more commands exceeded their baseline by more than 75%.")
process.exit(1)
}

console.log("\nAll commands within the 20% regression threshold.")
console.log("\nAll commands within the 75% regression threshold.")
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
7 changes: 4 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,10 @@ if (firstArg === 'status') {
let earlyConfig: LoadConfigResult | undefined
const hasProfileFlag = argv.includes('--command-profile')
const CONTEXT_NAMESPACES = new Set(['stack', 'cloud'])
// skip early config when Commander will just print help — no action will fire.
// operands = [namespace, subcommand?, ...rest]; a sub-subcommand is at operands[2].
const willJustPrintHelp = CONTEXT_NAMESPACES.has(firstArg ?? '') && operands.length < 3
// skip early config when Commander will just print help -- no action will fire.
// Also skip for any --help invocation (wantsHelp) unless --command-profile is set,
// since help text never needs credentials or context resolution.
const willJustPrintHelp = wantsHelp || (CONTEXT_NAMESPACES.has(firstArg ?? '') && operands.length < 3)
if (firstArg != null && (!willJustPrintHelp || hasProfileFlag)) {
const SKIP_EARLY_CONFIG: ReadonlySet<string> = new Set([
'version', 'extension', 'status', 'completion', '__complete',
Expand Down
Loading