-
-
Notifications
You must be signed in to change notification settings - Fork 4
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 file caching utility function #20
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
/** | ||
* The number of milliseconds in an hour, used as a cache age. | ||
*/ | ||
export const ONE_HOUR = 60 * 60 * 1000; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,261 @@ | ||
import { readJsonFile, writeJsonFile } from '@metamask/utils/node'; | ||
import path from 'path'; | ||
|
||
import { fetchOrPopulateFileCache } from './fetch-or-populate-file-cache'; | ||
import { withinSandbox, fakeDateOnly } from '../tests/helpers'; | ||
|
||
describe('fetchOrPopulateFileCache', () => { | ||
beforeEach(() => { | ||
fakeDateOnly(); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.useRealTimers(); | ||
}); | ||
|
||
describe('if the given file does not already exist', () => { | ||
it('saves the return value of the given function in the file as JSON along with its created time', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const data = { foo: 'bar' }; | ||
|
||
await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => data, | ||
}); | ||
|
||
const cache = await readJsonFile(filePath); | ||
expect(cache).toStrictEqual({ | ||
ctime: '2023-01-01T00:00:00.000Z', | ||
data, | ||
}); | ||
}); | ||
}); | ||
|
||
it('returns the data that was cached', async () => { | ||
await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const dataToCache = { foo: 'bar' }; | ||
|
||
const cachedData = await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => dataToCache, | ||
}); | ||
|
||
expect(cachedData).toStrictEqual(dataToCache); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('if the given file already exists', () => { | ||
describe('and no explicit max age is given', () => { | ||
describe('and it was created less than an hour ago', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Have you considered parameterize these tests? We might be able to cut down on the repetition that way. These all look like they're testing the same thing with different inputs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or we could make the max age not configurable. Not sure how likely it is that we'll want to set that in practice |
||
it('does not overwrite the cache', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const data = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T00:30:00Z').toISOString(), | ||
data, | ||
}); | ||
|
||
await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => data, | ||
}); | ||
|
||
const cache = await readJsonFile(filePath); | ||
expect(cache).toStrictEqual({ | ||
ctime: '2023-01-01T00:30:00.000Z', | ||
data, | ||
}); | ||
}, | ||
); | ||
}); | ||
|
||
it('returns the data in the file', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const data = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T00:30:00Z').toISOString(), | ||
data, | ||
}); | ||
|
||
const cachedData = await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => data, | ||
}); | ||
|
||
expect(cachedData).toStrictEqual(data); | ||
}, | ||
); | ||
}); | ||
}); | ||
|
||
describe('and it was created more than an hour ago', () => { | ||
it('overwrites the cache', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const dataToCache = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T01:00:01Z').toISOString(), | ||
data: dataToCache, | ||
}); | ||
|
||
await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => dataToCache, | ||
}); | ||
|
||
const cache = await readJsonFile(filePath); | ||
expect(cache).toStrictEqual({ | ||
ctime: '2023-01-01T01:00:01.000Z', | ||
data: dataToCache, | ||
}); | ||
}, | ||
); | ||
}); | ||
|
||
it('returns the data in the file', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const dataToCache = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T01:00:01Z').toISOString(), | ||
data: dataToCache, | ||
}); | ||
|
||
const cachedData = await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => dataToCache, | ||
}); | ||
|
||
expect(cachedData).toStrictEqual(dataToCache); | ||
}, | ||
); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('and a max age is given', () => { | ||
describe('and it was created less than <max age> seconds ago', () => { | ||
it('does not overwrite the cache', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const data = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T00:00:04Z').toISOString(), | ||
data, | ||
}); | ||
|
||
await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => data, | ||
maxAge: 5000, | ||
}); | ||
|
||
const cache = await readJsonFile(filePath); | ||
expect(cache).toStrictEqual({ | ||
ctime: '2023-01-01T00:00:04.000Z', | ||
data, | ||
}); | ||
}, | ||
); | ||
}); | ||
|
||
it('returns the data in the file', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const data = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T00:00:04Z').toISOString(), | ||
data, | ||
}); | ||
|
||
const cachedData = await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => data, | ||
maxAge: 5000, | ||
}); | ||
|
||
expect(cachedData).toStrictEqual(data); | ||
}, | ||
); | ||
}); | ||
}); | ||
|
||
describe('and it was created more than an hour ago', () => { | ||
it('overwrites the cache', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const dataToCache = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T00:00:06Z').toISOString(), | ||
data: dataToCache, | ||
}); | ||
|
||
await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => dataToCache, | ||
maxAge: 5000, | ||
}); | ||
|
||
const cache = await readJsonFile(filePath); | ||
expect(cache).toStrictEqual({ | ||
ctime: '2023-01-01T00:00:06.000Z', | ||
data: dataToCache, | ||
}); | ||
}, | ||
); | ||
}); | ||
|
||
it('returns the data in the file', async () => { | ||
jest.setSystemTime(new Date('2023-01-01T00:00:00Z')); | ||
|
||
await withinSandbox( | ||
async ({ directoryPath: sandboxDirectoryPath }) => { | ||
const filePath = path.join(sandboxDirectoryPath, 'cache'); | ||
const dataToCache = { foo: 'bar' }; | ||
await writeJsonFile(filePath, { | ||
ctime: new Date('2023-01-01T00:00:06Z').toISOString(), | ||
data: dataToCache, | ||
}); | ||
|
||
const cachedData = await fetchOrPopulateFileCache({ | ||
filePath, | ||
getDataToCache: () => dataToCache, | ||
maxAge: 5000, | ||
}); | ||
|
||
expect(cachedData).toStrictEqual(dataToCache); | ||
}, | ||
); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
import { fileExists, readJsonFile, writeJsonFile } from '@metamask/utils/node'; | ||
import type { Json } from '@metamask/utils/node'; | ||
|
||
import { ONE_HOUR } from './constants'; | ||
import { createModuleLogger, projectLogger } from './logging-utils'; | ||
|
||
const log = createModuleLogger(projectLogger, 'fetch-or-populate-file-cache'); | ||
|
||
/** | ||
* The data stored in the cache file. | ||
*/ | ||
type FileCache<Data extends Json> = { | ||
/** | ||
* When the data was stored. | ||
*/ | ||
ctime: string; | ||
/** | ||
* The cached data. | ||
*/ | ||
data: Data; | ||
}; | ||
|
||
/** | ||
* How long to cache data retrieved from an API (to prevent rate limiting). | ||
* | ||
* Equal to 1 hour. | ||
*/ | ||
const DEFAULT_MAX_AGE = ONE_HOUR; | ||
|
||
/** | ||
* Avoids rate limits when making requests to an API by consulting a file cache. | ||
* | ||
* Reads the given cache file and returns the data within it if it exists and is | ||
* fresh enough; otherwise runs the given function and saves its return value to | ||
* the file. | ||
* | ||
* @param args - The arguments to this function. | ||
* @param args.filePath - The path to the file where the data should be saved. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Perhaps we could update these docs, or the parameter name (or both) to make it more clear that this is the path to the cache file? When I first read this, I thought this was how the data was output (e.g. that this was a "download data and write to this file" function. |
||
* @param args.getDataToCache - A function to get the data that should be cached | ||
* if the cache does not exist or is stale. | ||
* @param args.maxAge - The amount of time (in milliseconds) that the cache is | ||
* considered "fresh". Affects subsequent calls: if `fetchOrPopulateFileCache` | ||
* is called again with the same file path within the duration specified here, | ||
* `getDataToCache` will not get called again, otherwise it will. Defaults to 1 | ||
* hour. | ||
*/ | ||
export async function fetchOrPopulateFileCache<Data extends Json>({ | ||
filePath, | ||
maxAge = DEFAULT_MAX_AGE, | ||
getDataToCache, | ||
}: { | ||
filePath: string; | ||
maxAge?: number; | ||
getDataToCache: () => Data | Promise<Data>; | ||
}): Promise<Data> { | ||
const now = new Date(); | ||
|
||
if (await fileExists(filePath)) { | ||
const cache = await readJsonFile<FileCache<Data>>(filePath); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Have you considered trying to read the file directly and handling the "does not exist" error, rather than checking for existence first? That would align better with what the Node.js docs recommend:
|
||
const createdDate = new Date(cache.ctime); | ||
|
||
if (now.getTime() - createdDate.getTime() <= maxAge) { | ||
log(`Reusing fresh cached data under ${filePath}`); | ||
return cache.data; | ||
} | ||
} | ||
|
||
log( | ||
`Cache does not exist or is stale; preparing data to write to ${filePath}`, | ||
); | ||
const dataToCache = await getDataToCache(); | ||
await writeJsonFile(filePath, { | ||
ctime: now.toISOString(), | ||
data: dataToCache, | ||
}); | ||
return dataToCache; | ||
} |
This file was deleted.
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, this should be covered just via it being imported. Any reason to exclude it?