From e451f7a63d3e200443ccf347741ce97fced029c1 Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Mon, 28 May 2018 16:46:16 +0200 Subject: [PATCH] perf: implemented caching of module version As part of this I made TreeCache, which makes it easy to cache values in-process and invalidate those entries based on a surrounding context, which can be used for other scenarios as well. For the module versions, the cache is invalidated by FSWatcher when watched modules are modified. --- src/cache.ts | 258 ++++++++++++++++++++++++++++++++++++++ src/garden.ts | 5 +- src/plugin-context.ts | 88 ++++++++++++- src/types/module.ts | 48 ++----- src/watch.ts | 11 +- test/src/cache.ts | 200 +++++++++++++++++++++++++++++ test/src/commands/push.ts | 101 +++++++-------- test/src/types/module.ts | 39 +++++- 8 files changed, 643 insertions(+), 107 deletions(-) create mode 100644 src/cache.ts create mode 100644 test/src/cache.ts diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000000..8f432e0a97 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2018 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { + isEqual, +} from "lodash" +import { + normalize, + parse, + sep, +} from "path" +import { + InternalError, + NotFoundError, + ParameterError, +} from "./exceptions" + +export type CacheKey = string[] +export type CacheContext = string[] +export type CurriedKey = string + +export type CacheValue = string | number | boolean | null | object +export type CacheValues = Map + +interface CacheEntry { + key: CacheKey + value: CacheValue + contexts: { [curriedContext: string]: CacheContext } +} + +type CacheEntries = Map + +interface ContextNode { + key: CacheContext + children: { [contextPart: string]: ContextNode } + entries: Set +} + +/** + * A simple in-memory cache that additionally indexes keys in a tree by a seperate context key, so that keys + * can be invalidated based on surrounding context. + * + * For example, we can cache the version of a directory path, and then invalidate every cached key under a + * parent path: + * + * ``` + * const cache = new TreeCache() + * + * # The context parameter (last parameter) here is the path to the module source + * cache.set(["modules", "my-module-a"], module, ["modules", "module-path-a"]) + * cache.set(["modules", "my-module-b"], module, ["modules", "module-path-b"]) + * + * # Invalidates the cache for module-a + * cache.invalidate(["modules", "module-path-a"]) + * + * # Also invalidates the cache for module-a + * cache.invalidateUp(["modules", "module-path-a", "subdirectory"]) + * + * # Invalidates the cache for both modules + * cache.invalidateDown(["modules"]) + * ``` + * + * This is useful, for example, when listening for filesystem events to make sure cached items stay in + * sync after making changes to sources. + * + * A single cache entry can also have multiple invalidation contexts, which is helpful when a cache key + * can be invalidated by changes to multiple contexts (say for a module version, which should also be + * invalidated when dependencies are updated). + * + */ +export class TreeCache { + private readonly cache: CacheEntries + private readonly contextTree: ContextNode + + constructor() { + this.cache = new Map() + this.contextTree = makeContextNode([]) + } + + set(key: CacheKey, value: CacheValue, ...contexts: CacheContext[]) { + if (key.length === 0) { + throw new ParameterError(`Cache key must have at least one part`, { key, contexts }) + } + + if (contexts.length === 0) { + throw new ParameterError(`Must specify at least one context`, { key, contexts }) + } + + const curriedKey = curry(key) + let entry = this.cache.get(curriedKey) + + if (entry === undefined) { + entry = { key, value, contexts: {} } + this.cache.set(curriedKey, entry) + } else { + // merge with the existing entry + entry.value = value + } + + contexts.forEach(c => entry!.contexts[curry(c)] = c) + + for (const context of Object.values(contexts)) { + let node = this.contextTree + + if (context.length === 0) { + throw new ParameterError(`Context key must have at least one part`, { key, context }) + } + + const contextKey: CacheContext = [] + + for (const part of context) { + contextKey.push(part) + + if (node.children[part]) { + node = node.children[part] + } else { + node = node.children[part] = makeContextNode(contextKey) + } + } + + node.entries.add(curriedKey) + } + } + + get(key: CacheKey): CacheValue | undefined { + const entry = this.cache.get(curry(key)) + return entry ? entry.value : undefined + } + + getOrThrow(key: CacheKey): CacheValue { + const value = this.get(key) + if (value === undefined) { + throw new NotFoundError(`Could not find key ${key} in cache`, { key }) + } + return value + } + + getByContext(context: CacheContext): CacheValues { + let pairs: [CacheKey, CacheValue][] = [] + + const node = this.getNode(context) + + if (node) { + pairs = Array.from(node.entries).map(curriedKey => { + const entry = this.cache.get(curriedKey) + if (!entry) { + throw new InternalError(`Invalid reference found in cache: ${curriedKey}`, { curriedKey }) + } + return <[CacheKey, CacheValue]>[entry.key, entry.value] + }) + } + + return new Map(pairs) + } + + /** + * Invalidates all cache entries whose context equals `context` + */ + invalidate(context: CacheContext) { + const node = this.getNode(context) + + if (node) { + // clear all cache entries on the node + this.clearNode(node, false) + } + } + + /** + * Invalidates all cache entries where the given `context` starts with the entries' context + * (i.e. the whole path from the tree root down to the context leaf) + */ + invalidateUp(context: CacheContext) { + let node = this.contextTree + + for (const part of context) { + node = node.children[part] + this.clearNode(node, false) + } + } + + /** + * Invalidates all cache entries whose context _starts_ with the given `context` + * (i.e. the context node and the whole tree below it) + */ + invalidateDown(context: CacheContext) { + const node = this.getNode(context) + + if (node) { + // clear all cache entries in the node and recursively through all child nodes + this.clearNode(node, true) + } + } + + private getNode(context: CacheContext) { + let node = this.contextTree + + for (const part of context) { + node = node.children[part] + + if (!node) { + // no cache keys under the given context + return + } + } + + return node + } + + private clearNode(node: ContextNode, clearChildNodes: boolean) { + for (const curriedKey of node.entries) { + const entry = this.cache.get(curriedKey) + + if (entry === undefined) { + return + } + + // also clear the invalidated entry from its other contexts + for (const context of Object.values(entry.contexts)) { + if (!isEqual(context, node.key)) { + const otherNode = this.getNode(context) + otherNode && otherNode.entries.delete(curriedKey) + } + } + + this.cache.delete(curriedKey) + } + + node.entries = new Set() + + if (clearChildNodes) { + for (const child of Object.values(node.children)) { + this.clearNode(child, true) + } + } + } +} + +function makeContextNode(key: CacheContext): ContextNode { + return { + key, + children: {}, + entries: new Set(), + } +} + +function curry(key: CacheKey | CacheContext) { + return JSON.stringify(key) +} + +export function pathToCacheContext(path: string): CacheContext { + const parsed = parse(normalize(path)) + return ["path", ...parsed.dir.split(sep)] +} diff --git a/src/garden.ts b/src/garden.ts index 8dc34cdab2..a5c8db0a45 100644 --- a/src/garden.ts +++ b/src/garden.ts @@ -22,6 +22,7 @@ import { keyBy, } from "lodash" import * as Joi from "joi" +import { TreeCache } from "./cache" import { PluginContext, createPluginContext, @@ -148,7 +149,8 @@ export class Garden { private taskGraph: TaskGraph private readonly configKeyNamespaces: string[] - vcs: VcsHandler + public readonly vcs: VcsHandler + public readonly cache: TreeCache constructor( public readonly projectRoot: string, @@ -164,6 +166,7 @@ export class Garden { this.log = logger || getLogger() // TODO: Support other VCS options. this.vcs = new GitHandler(this.projectRoot) + this.cache = new TreeCache() this.modules = {} this.services = {} diff --git a/src/plugin-context.ts b/src/plugin-context.ts index 2413ce9942..1bd555dfdb 100644 --- a/src/plugin-context.ts +++ b/src/plugin-context.ts @@ -8,6 +8,14 @@ import Bluebird = require("bluebird") import chalk from "chalk" +import { + pathExists, + readFile, +} from "fs-extra" +import { join } from "path" +import { CacheContext } from "./cache" +import { GARDEN_VERSIONFILE_NAME } from "./constants" +import { ConfigurationError } from "./exceptions" import { Garden, } from "./garden" @@ -15,8 +23,12 @@ import { EntryStyle } from "./logger/types" import { TaskResults } from "./task-graph" import { PrimitiveMap, + validate, } from "./types/common" -import { Module } from "./types/module" +import { + Module, + versionFileSchema, +} from "./types/module" import { ModuleActions, Provider, @@ -84,6 +96,7 @@ import { registerCleanupFunction, sleep, } from "./util" +import { TreeVersion } from "./vcs/base" import { autoReloadModules, computeAutoReloadDependants, @@ -114,7 +127,6 @@ export type WrappedFromGarden = Pick(params: PluginContextServiceParams>) => Promise, + invalidateCache: (context: CacheContext) => void + invalidateCacheUp: (context: CacheContext) => void + invalidateCacheDown: (context: CacheContext) => void getModuleBuildPath: (moduleName: string) => Promise + getModuleVersion: (module: Module, force?: boolean) => Promise stageBuild: (moduleName: string) => Promise getStatus: () => Promise processModules: (params: ProcessModulesParams) => Promise @@ -426,9 +442,16 @@ export function createPluginContext(garden: Garden): PluginContext { //region Helper Methods //=========================================================================== - resolveModule: async (nameOrLocation: string) => { - const module = await garden.resolveModule(nameOrLocation) - return module || null + invalidateCache: (context: CacheContext) => { + garden.cache.invalidate(context) + }, + + invalidateCacheUp: (context: CacheContext) => { + garden.cache.invalidateUp(context) + }, + + invalidateCacheDown: (context: CacheContext) => { + garden.cache.invalidateDown(context) }, stageBuild: async (moduleName: string) => { @@ -441,6 +464,59 @@ export function createPluginContext(garden: Garden): PluginContext { return await garden.buildDir.buildPath(module) }, + getModuleVersion: async (module: Module, force = false) => { + const cacheKey = ["moduleVersions", module.name] + + if (!force) { + const cached = garden.cache.get(cacheKey) + + if (cached) { + return cached + } + } + + const buildDependencies = await module.getBuildDependencies() + const cacheContexts = buildDependencies.concat([module]).map(m => m.getCacheContext()) + const versionFilePath = join(module.path, GARDEN_VERSIONFILE_NAME) + + if (await pathExists(versionFilePath)) { + // this is used internally to specify version outside of source control + const versionFileContents = (await readFile(versionFilePath)).toString().trim() + + if (!!versionFileContents) { + try { + const fileVersion = validate(JSON.parse(versionFileContents), versionFileSchema) + garden.cache.set(cacheKey, fileVersion, ...cacheContexts) + return fileVersion + } catch (err) { + throw new ConfigurationError( + `Unable to parse ${GARDEN_VERSIONFILE_NAME} as valid version file in module directory ${module.path}`, + { + modulePath: module.path, + versionFilePath, + versionFileContents, + }, + ) + } + } + } + + const treeVersion = await garden.vcs.getTreeVersion([module.path]) + + const versionChain: TreeVersion[] = await Bluebird.map( + buildDependencies, + async (m: Module) => await m.getVersion(), + ) + versionChain.push(treeVersion) + + // The module version is the latest of any of the dependency modules or itself. + const sortedVersions = await garden.vcs.sortVersions(versionChain) + const version = sortedVersions[0] + + garden.cache.set(cacheKey, version, ...cacheContexts) + return version + }, + getStatus: async () => { const envStatus: EnvironmentStatusMap = await ctx.getEnvironmentStatus({}) const services = await ctx.getServices() @@ -497,7 +573,7 @@ export function createPluginContext(garden: Garden): PluginContext { } } - const watcher = new FSWatcher(ctx.projectRoot) + const watcher = new FSWatcher(ctx) // TODO: should the prefix here be different or set explicitly per run? await watcher.watchModules(modulesToWatch, "addTasksForAutoReload/", diff --git a/src/types/module.ts b/src/types/module.ts index 2c6842379d..9c251d0d91 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -7,10 +7,6 @@ */ import * as Bluebird from "bluebird" -import { - pathExists, - readFile, -} from "fs-extra" import * as Joi from "joi" import { flatten, @@ -18,8 +14,6 @@ import { set, uniq, } from "lodash" -import { join } from "path" -import { GARDEN_VERSIONFILE_NAME } from "../constants" import { ConfigurationError } from "../exceptions" import { PluginContext } from "../plugin-context" import { DeployTask } from "../tasks/deploy" @@ -49,6 +43,7 @@ import { TestConfig, TestSpec, } from "./test" +import { pathToCacheContext } from "../cache" export interface BuildCopySpec { source: string @@ -90,7 +85,7 @@ export interface BuildConfig { dependencies: BuildDependencyConfig[], } -const versionFileSchema = Joi.object() +export const versionFileSchema = Joi.object() .keys({ versionString: Joi.string().required(), latestCommit: Joi.string().required(), @@ -209,41 +204,12 @@ export class Module< set(this.config, key, value) } - async getVersion(): Promise { - const versionFilePath = join(this.path, GARDEN_VERSIONFILE_NAME) - - if (await pathExists(versionFilePath)) { - // this is used internally to specify version outside of source control - const versionFileContents = (await readFile(versionFilePath)).toString().trim() - - if (!!versionFileContents) { - try { - return validate(JSON.parse(versionFileContents), versionFileSchema) - } catch (err) { - throw new ConfigurationError( - `Unable to parse ${GARDEN_VERSIONFILE_NAME} as valid version file in module directory ${this.path}`, - { - modulePath: this.path, - versionFilePath, - versionFileContents, - }, - ) - } - } - } - - const treeVersion = await this.ctx.vcs.getTreeVersion([this.path]) - - const versionChain: TreeVersion[] = await Bluebird.map( - await this.getBuildDependencies(), - async (m: Module) => await m.getVersion(), - ) - versionChain.push(treeVersion) - - // The module version is the latest of any of the dependency modules or itself. - const sortedVersions = await this.ctx.vcs.sortVersions(versionChain) + getCacheContext() { + return pathToCacheContext(this.path) + } - return sortedVersions[0] + async getVersion(force?: boolean): Promise { + return this.ctx.getModuleVersion(this, force) } async getBuildPath() { diff --git a/src/watch.ts b/src/watch.ts index 68673412e6..c00b8570d0 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -10,6 +10,7 @@ import { map as bluebirdMap } from "bluebird" import { Client } from "fb-watchman" import { keyBy, uniqBy, values } from "lodash" import { relative, resolve } from "path" +import { pathToCacheContext } from "./cache" import { Module } from "./types/module" import { PluginContext } from "./plugin-context" @@ -81,10 +82,12 @@ export type SubscriptionResponse = { export class FSWatcher { private readonly client private capabilityCheckComplete: boolean + private projectRoot: string - constructor(private projectRoot: string) { + constructor(private ctx: PluginContext) { this.client = new Client() this.capabilityCheckComplete = false + this.projectRoot = ctx.projectRoot } /* @@ -113,6 +116,8 @@ export class FSWatcher { modules: Module[], subscriptionPrefix: string, changeHandler: (module: Module, response: SubscriptionResponse) => Promise, ) { + const _this = this + if (!this.capabilityCheckComplete) { await this.capabilityCheck({ optional: [], required: ["relative_root"] }) } @@ -144,6 +149,10 @@ export class FSWatcher { return } + // invalidate the cache for anything attached to the module path or upwards in the directory tree + const cacheContext = pathToCacheContext(changedModule.path) + _this.ctx.invalidateCacheUp(cacheContext) + await changeHandler(changedModule, response) }) } diff --git a/test/src/cache.ts b/test/src/cache.ts new file mode 100644 index 0000000000..896a6b1f53 --- /dev/null +++ b/test/src/cache.ts @@ -0,0 +1,200 @@ +import { TreeCache } from "../../src/cache" +import { expect } from "chai" +import { expectError } from "../helpers" + +describe("TreeCache", () => { + let cache: TreeCache + + beforeEach(() => { + cache = new TreeCache() + }) + + const mapToPairs = (m: Map) => Array.from(m.entries()) + + it("should store and retrieve a one-part key", () => { + const key = ["my-key"] + const value = "my-value" + const context = ["some", "context"] + + cache.set(key, value, context) + + expect(cache.get(key)).to.equal(value) + }) + + it("should store and retrieve a multi-part key", () => { + const key = ["multi", "part", "key"] + const value = "my-value" + const context = ["some", "context"] + + cache.set(key, value, context) + + expect(cache.get(key)).to.equal(value) + }) + + describe("set", () => { + it("should accept multiple contexts", () => { + const key = ["my-key"] + const value = "my-value" + const contextA = ["context", "a"] + const contextB = ["context", "b"] + + cache.set(key, value, contextA, contextB) + + expect(cache.get(key)).to.equal(value) + expect(mapToPairs(cache.getByContext(contextA))).to.eql([[key, value]]) + expect(mapToPairs(cache.getByContext(contextB))).to.eql([[key, value]]) + }) + + it("should merge contexts when setting key multiple times", () => { + const key = ["my-key"] + const value = "my-value" + const contextA = ["context", "a"] + const contextB = ["context", "b"] + + cache.set(key, value, contextA) + cache.set(key, value, contextB) + + expect(cache.get(key)).to.equal(value) + expect(mapToPairs(cache.getByContext(contextA))).to.eql([[key, value]]) + expect(mapToPairs(cache.getByContext(contextB))).to.eql([[key, value]]) + }) + + it("should update value when setting key multiple times", () => { + const key = ["my-key"] + const value = "my-value" + const valueB = "my-new-value" + const context = ["context", "a"] + + cache.set(key, value, context) + cache.set(key, valueB, context) + + expect(cache.get(key)).to.equal(valueB) + }) + + it("should throw with an empty key", async () => { + const key = [] + const value = "my-value" + const context = ["some", "context"] + + await expectError(() => cache.set(key, value, context), "parameter") + }) + + it("should throw with no context", async () => { + const key = ["my-key"] + const value = "my-value" + + await expectError(() => cache.set(key, value), "parameter") + }) + + it("should throw with an empty context", async () => { + const key = ["my-key"] + const value = "my-value" + const context = [] + + await expectError(() => cache.set(key, value, context), "parameter") + }) + }) + + describe("get", () => { + it("should return undefined when key does not exist", () => { + expect(cache.get(["bla"])).to.be.undefined + }) + }) + + describe("getOrThrow", () => { + it("should throw when key does not exist", async () => { + await expectError(() => cache.getOrThrow(["bla"]), "not-found") + }) + }) + + describe("invalidate", () => { + it("should invalidate keys with the exact given context", () => { + const keyA = ["key", "a"] + const valueA = "value-a" + const contextA = ["context", "a"] + + cache.set(keyA, valueA, contextA) + + const keyB = ["key", "b"] + const valueB = "value-b" + const contextB = ["context", "b"] + + cache.set(keyB, valueB, contextB) + + cache.invalidate(contextA) + + expect(cache.get(keyA)).to.be.undefined + expect(cache.get(keyB)).to.equal(valueB) + }) + + it("should remove entry from all associated contexts", () => { + const key = ["my", "key"] + const value = "my-value" + const contextA = ["some", "context"] + const contextB = ["other", "context"] + + cache.set(key, value, contextA, contextB) + cache.invalidate(contextB) + + expect(cache.get(key)).to.be.undefined + expect(mapToPairs(cache.getByContext(contextA))).to.eql([]) + expect(mapToPairs(cache.getByContext(contextB))).to.eql([]) + }) + }) + + describe("invalidateUp", () => { + it("should invalidate keys with the specified context and above in the tree", () => { + const keyA = ["key", "a"] + const valueA = "value-a" + const contextA = ["section-a", "a"] + + cache.set(keyA, valueA, contextA) + + const keyB = ["key", "b"] + const valueB = "value-b" + const contextB = ["section-a", "a", "nested"] + + cache.set(keyB, valueB, contextB) + + const keyC = ["key", "c"] + const valueC = "value-c" + const contextC = ["section-b", "c"] + + cache.set(keyC, valueC, contextC) + + cache.invalidateUp(contextB) + + expect(cache.get(keyA)).to.be.undefined + expect(cache.get(keyB)).to.be.undefined + expect(cache.get(keyC)).to.equal(valueC) + }) + }) + + describe("invalidateDown", () => { + it("should invalidate keys with the specified context and below in the tree", () => { + const keyA = ["key", "a"] + const valueA = "value-a" + const contextA = ["section-a", "a"] + + cache.set(keyA, valueA, contextA) + + const keyB = ["key", "b"] + const valueB = "value-b" + const contextB = ["section-a", "a", "nested"] + + cache.set(keyB, valueB, contextB) + + const keyC = ["key", "c"] + const valueC = "value-c" + const contextC = ["section-b", "c"] + + cache.set(keyC, valueC, contextC) + + cache.invalidateDown(["section-a"]) + + expect(cache.get(keyA)).to.be.undefined + expect(cache.get(keyB)).to.be.undefined + expect(cache.get(keyC)).to.equal(valueC) + }) + }) +}) diff --git a/test/src/commands/push.ts b/test/src/commands/push.ts index 337d80f09e..4381c63342 100644 --- a/test/src/commands/push.ts +++ b/test/src/commands/push.ts @@ -79,17 +79,6 @@ async function getTestContext() { describe("PushCommand", () => { // TODO: Verify that services don't get redeployed when same version is already deployed. - - beforeEach(() => { - td.replace(Module.prototype, "getVersion", async (): Promise => { - return { - versionString: "12345", - latestCommit: "12345", - dirtyTimestamp: null, - } - }) - }) - it("should build and push modules in a project", async () => { const ctx = await getTestContext() const command = new PushCommand() @@ -201,56 +190,54 @@ describe("PushCommand", () => { }) }) - it("should throw if module is dirty", async () => { - td.replace(Module.prototype, "getVersion", async (): Promise => { - return { - versionString: "12345", - latestCommit: "12345", - dirtyTimestamp: 12345, - } + context("module is dirty", () => { + beforeEach(() => { + td.replace(Module.prototype, "getVersion", async (): Promise => { + return { + versionString: "012345", + latestCommit: "012345", + dirtyTimestamp: 12345, + } + }) }) - const ctx = await getTestContext() - const command = new PushCommand() - - await expectError(() => command.action( - ctx, - { - module: "module-a", - }, - { - "allow-dirty": false, - "force-build": false, - }, - ), "runtime") - }) - - it("should optionally allow pushing dirty commits", async () => { - td.replace(Module.prototype, "getVersion", async (): Promise => { - return { - versionString: "12345", - latestCommit: "12345", - dirtyTimestamp: 12345, - } + afterEach(() => td.reset()) + + it("should throw if module is dirty", async () => { + const ctx = await getTestContext() + const command = new PushCommand() + + await expectError(() => command.action( + ctx, + { + module: "module-a", + }, + { + "allow-dirty": false, + "force-build": false, + }, + ), "runtime") }) - const ctx = await getTestContext() - const command = new PushCommand() - - const { result } = await command.action( - ctx, - { - module: "module-a", - }, - { - "allow-dirty": true, - "force-build": true, - }, - ) - - expect(taskResultOutputs(result!)).to.eql({ - "build.module-a": { fresh: true }, - "push.module-a": { pushed: true }, + it("should optionally allow pushing dirty commits", async () => { + const ctx = await getTestContext() + const command = new PushCommand() + + const { result } = await command.action( + ctx, + { + module: "module-a", + }, + { + "allow-dirty": true, + "force-build": true, + }, + ) + + expect(taskResultOutputs(result!)).to.eql({ + "build.module-a": { fresh: true }, + "push.module-a": { pushed: true }, + }) }) }) }) diff --git a/test/src/types/module.ts b/test/src/types/module.ts index 420993f61c..bee3aac0df 100644 --- a/test/src/types/module.ts +++ b/test/src/types/module.ts @@ -1,9 +1,15 @@ import { Module } from "../../../src/types/module" import { resolve } from "path" -import { dataDir, makeTestContextA, makeTestContext } from "../../helpers" +import { + dataDir, + makeTestContextA, + makeTestContext, + makeTestGardenA, +} from "../../helpers" import { expect } from "chai" import { loadConfig } from "../../../src/types/config" +const getVersion = Module.prototype.getVersion const modulePathA = resolve(dataDir, "test-project-a", "module-a") describe("Module", () => { @@ -43,6 +49,37 @@ describe("Module", () => { }) }) + describe("getVersion", () => { + let stub: any + + beforeEach(() => { + stub = Module.prototype.getVersion + Module.prototype.getVersion = getVersion + }) + + afterEach(() => { + Module.prototype.getVersion = stub + }) + + it("should use cached version if available", async () => { + const garden = await makeTestGardenA() + const ctx = garden.pluginContext + const config = await loadConfig(ctx.projectRoot, modulePathA) + const module = new Module(ctx, config.module!, [], []) + + const cachedVersion = { + versionString: "0123456789", + latestCommit: "0123456789", + dirtyTimestamp: null, + } + garden.cache.set(["moduleVersions", module.name], cachedVersion, module.getCacheContext()) + + const version = await module.getVersion() + + expect(version).to.eql(cachedVersion) + }) + }) + describe("resolveConfig", () => { it("should resolve template strings", async () => { process.env.TEST_VARIABLE = "banana"