Skip to content

Commit

Permalink
perf: implemented caching of module version
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
edvald committed Jun 7, 2018
1 parent c54c16d commit e451f7a
Show file tree
Hide file tree
Showing 8 changed files with 643 additions and 107 deletions.
258 changes: 258 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/*
* Copyright (C) 2018 Garden Technologies, Inc. <[email protected]>
*
* 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<CacheKey, CacheValue>

interface CacheEntry {
key: CacheKey
value: CacheValue
contexts: { [curriedContext: string]: CacheContext }
}

type CacheEntries = Map<CurriedKey, CacheEntry>

interface ContextNode {
key: CacheContext
children: { [contextPart: string]: ContextNode }
entries: Set<CurriedKey>
}

/**
* 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<CurriedKey, CacheEntry>()
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<CacheKey, CacheValue>(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<CurriedKey>()

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<CurriedKey>(),
}
}

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)]
}
5 changes: 4 additions & 1 deletion src/garden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
keyBy,
} from "lodash"
import * as Joi from "joi"
import { TreeCache } from "./cache"
import {
PluginContext,
createPluginContext,
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {}
Expand Down
Loading

0 comments on commit e451f7a

Please sign in to comment.