diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml index 0169771bc2b..708b2f677ba 100644 --- a/.idea/jsLibraryMappings.xml +++ b/.idea/jsLibraryMappings.xml @@ -4,4 +4,4 @@ - \ No newline at end of file + diff --git a/lib/cache/base.js b/lib/cache/base.js new file mode 100644 index 00000000000..24528421d07 --- /dev/null +++ b/lib/cache/base.js @@ -0,0 +1,93 @@ +// Copyright (c) 2018, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +const crypto = require('crypto'), + logger = require('../logger').logger; + +const HashVersion = 'Compiler Explorer Cache Version 1'; +const ReportEveryMs = 5 * 60 * 1000; + +class BaseCache { + constructor(name) { + this.name = name; + this.gets = 0; + this.hits = 0; + this.puts = 0; + this.reporter = setInterval(() => this.report(), ReportEveryMs); + } + + dispose() { + clearInterval(this.reporter); + } + + stats() { + return {hits: this.hits, puts: this.puts, gets: this.gets}; + } + + statString() { + const pc = this.gets ? (100 * this.hits) / this.gets : 0; + const misses = this.gets - this.hits; + return `${this.puts} puts; ${this.gets} gets, ${this.hits} hits, ${misses} misses (${pc.toFixed(2)}%)`; + } + + report() { + logger.info(`${this.name}: cache stats: ${this.statString()}`); + } + + static hash(object) { + return crypto.createHmac('sha256', HashVersion) + .update(JSON.stringify(object)) + .digest('hex'); + } + + get(key) { + this.gets++; + return this.getInternal(key) + .then(result => { + if (result.hit) { + this.hits++; + } + return result; + }); + } + + put(key, value, creator) { + if (!(value instanceof Buffer)) + value = new Buffer(value); + this.puts++; + return this.putInternal(key, value, creator); + } + + // eslint-disable-next-line no-unused-vars + getInternal(key) { + return Promise.reject("should be implemented in subclass"); + } + + // eslint-disable-next-line no-unused-vars + putInternal(key, value, creator) { + return Promise.reject("should be implemented in subclass"); + } +} + +module.exports = BaseCache; diff --git a/lib/cache/in-memory.js b/lib/cache/in-memory.js new file mode 100644 index 00000000000..1c64ebdba54 --- /dev/null +++ b/lib/cache/in-memory.js @@ -0,0 +1,59 @@ +// Copyright (c) 2018, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +const LRU = require('lru-cache'), + BaseCache = require('./base.js'); + +class InMemoryCache extends BaseCache { + constructor(cacheMb) { + super(`InMemoryCache(${cacheMb}Mb)`); + this.cache = LRU({ + max: cacheMb * 1024 * 1024, + length: n => { + if (n instanceof Buffer) + return n.length; + return JSON.stringify(n).length; + } + }); + } + + statString() { + return `${super.statString()}, LRU has ${this.cache.itemCount} item(s) totalling ${this.cache.length} bytes`; + } + + getInternal(key) { + const cached = this.cache.get(key); + return Promise.resolve({ + hit: !!cached, + data: cached + }); + } + + putInternal(key, value/*, creator*/) { + this.cache.set(key, value); + return Promise.resolve(); + } +} + +module.exports = InMemoryCache; diff --git a/lib/cache/multi.js b/lib/cache/multi.js new file mode 100644 index 00000000000..04a530cfc11 --- /dev/null +++ b/lib/cache/multi.js @@ -0,0 +1,58 @@ +// Copyright (c) 2018, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +const BaseCache = require('./base.js'); + +// A write-through multiple cache. +// Writes get pushed to all caches, but reads are serviced from the first cache that returns +// a hit. +class MultiCache extends BaseCache { + constructor(...upstream) { + super("Multi"); + this.upstream = upstream; + } + + statString() { + return `${super.statString()}. ${this.upstream.map(c => `${c.name}: ${c.statString()}`).join(". ")}`; + } + + getInternal(key) { + let promiseChain = Promise.resolve({hit: false}); + for (let cache of this.upstream) { + promiseChain = promiseChain.then(upstream => { + if (upstream.hit) return upstream; + return cache.get(key); + }); + } + return promiseChain; + } + + putInternal(object, value, creator) { + return Promise.all(this.upstream.map(cache => { + return cache.put(object, value, creator); + })); + } +} + +module.exports = MultiCache; diff --git a/lib/cache/on-disk.js b/lib/cache/on-disk.js new file mode 100644 index 00000000000..aee9552a4b2 --- /dev/null +++ b/lib/cache/on-disk.js @@ -0,0 +1,104 @@ +// Copyright (c) 2018, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +const LRU = require('lru-cache'), + fs = require('fs'), + path = require('path'), + mkdirp = require('mkdirp'), + BaseCache = require('./base.js'), + denodeify = require('denodeify'); + +// With thanks to https://gist.github.com/kethinov/6658166 +function getAllFiles(root, dir) { + dir = dir || root; + return fs.readdirSync(dir).reduce((files, file) => { + const fullPath = path.join(dir, file); + const name = path.relative(root, fullPath); + const isDirectory = fs.statSync(fullPath).isDirectory(); + return isDirectory ? [...files, ...getAllFiles(root, fullPath)] : [...files, {name, fullPath}]; + }, []); +} + +const readFile = denodeify(fs.readFile); +const writeFile = denodeify(fs.writeFile); + +class OnDiskCache extends BaseCache { + constructor(path, cacheMb) { + super(`OnDiskCache(${path}, ${cacheMb}mb)`); + this.path = path; + this.cache = LRU({ + max: cacheMb * 1024 * 1024, + length: n => n.size, + dispose: (key, n) => { + fs.unlink(n.path, () => {}); + } + }); + mkdirp.sync(path); + const info = getAllFiles(path).map(({name, fullPath}) => { + const stat = fs.statSync(fullPath); + return { + key: name, + sort: stat.ctimeMs, + data: { + path: fullPath, + size: stat.size + } + }; + }); + // Sort oldest first + info.sort((x, y) => { + return x.sort - y.sort; + }); + for (let i of info) { + this.cache.set(i.key, i.data); + } + } + + statString() { + return `${super.statString()}, LRU has ${this.cache.itemCount} item(s) ` + + `totalling ${this.cache.length} bytes on disk`; + } + + getInternal(key) { + const cached = this.cache.get(key); + if (!cached) return Promise.resolve({hit: false}); + return readFile(cached.path) + .then((data) => { + return {hit: true, data: data}; + }); + } + + putInternal(key, value) { + const info = { + path: path.join(this.path, key), + size: value.length + }; + return writeFile(info.path, value) + .then(() => { + this.cache.set(key, info); + }); + } +} + +module.exports = OnDiskCache; diff --git a/lib/cache/s3.js b/lib/cache/s3.js new file mode 100644 index 00000000000..a3e4572c4aa --- /dev/null +++ b/lib/cache/s3.js @@ -0,0 +1,63 @@ +// Copyright (c) 2018, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +const BaseCache = require('./base.js'); +const AWS = require('aws-sdk'); + +class S3Cache extends BaseCache { + constructor(bucket, path, region) { + super(`S3Cache(s3://${bucket}/${path} in ${region})`); + this.bucket = bucket; + this.path = path; + this.s3 = new AWS.S3({region}); + } + + statString() { + return `${super.statString()}, LRU has ${this.cache.itemCount} item(s) ` + + `totalling ${this.cache.length} bytes on disk`; + } + + getInternal(key) { + return this.s3.getObject({Bucket: this.bucket, Key: `${this.path}/${key}`}) + .promise() + .then((result) => { + return {hit: true, data: result.Body}; + }) + .catch((x) => { + if (x.code === 'NoSuchKey') return {hit: false}; + throw x; + }); + } + + putInternal(key, value, creator) { + return this.s3.putObject({ + Bucket: this.bucket, Key: `${this.path}/${key}`, Body: value, + StorageClass: "REDUCED_REDUNDANCY", + Metadata: {CreatedBy: creator} + }) + .promise(); + } +} + +module.exports = S3Cache; diff --git a/package.json b/package.json index c5c279ed757..130d471b628 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "jquery": "^3.3.1", "lru-cache": "^4.1.2", "lz-string": "^1.4.4", + "mkdirp": "^0.5.1", "monaco-editor": "0.10.1", "morgan": "^1.9.0", "nopt": "3.0.x", diff --git a/test/cache-tests.js b/test/cache-tests.js new file mode 100644 index 00000000000..cc8fc5cd113 --- /dev/null +++ b/test/cache-tests.js @@ -0,0 +1,208 @@ +// Copyright (c) 2018, Compiler Explorer Authors +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) , +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +const chai = require('chai'); +const chaiAsPromised = require("chai-as-promised"); +const InMemoryCache = require('../lib/cache/in-memory'); +const MultiCache = require('../lib/cache/multi'); +const OnDiskCache = require('../lib/cache/on-disk'); +const S3Cache = require('../lib/cache/s3'); +const temp = require('temp'); +const fs = require('fs'); +const path = require('path'); +const AWS = require('aws-sdk-mock'); + +chai.use(chaiAsPromised); +chai.should(); + +function newTempDir() { + temp.track(true); + return temp.mkdirSync({prefix: 'compiler-explorer-cache-tests', dir: process.env.tmpDir}); +} + +function basicTests(factory) { + it('should start empty', () => { + const cache = factory(); + cache.stats().should.eql({hits: 0, puts: 0, gets: 0}); + return cache.get('not a key', 'subsystem').should.eventually.contain({hit: false}) + .then((x) => { + cache.stats().should.eql({hits: 0, puts: 0, gets: 1}); + return x; + }); + }); + + it('should store and retrieve strings', () => { + const cache = factory(); + return cache.put('a key', 'a value', 'bob') + .then(() => { + cache.stats().should.eql({hits: 0, puts: 1, gets: 0}); + return cache.get('a key').should.eventually.eql({ + hit: true, + data: new Buffer('a value') + }); + }).then(x => { + cache.stats().should.eql({hits: 1, puts: 1, gets: 1}); + return x; + }); + }); + + it('should store and retrieve binary buffers', () => { + const cache = factory(); + const buffer = new Buffer(2 * 1024 * 1024); + buffer.fill('@'); + return cache.put('a key', buffer, 'bob') + .then(() => { + cache.stats().should.eql({hits: 0, puts: 1, gets: 0}); + return cache.get('a key').should.eventually.eql({ + hit: true, + data: buffer + }); + }).then(x => { + cache.stats().should.eql({hits: 1, puts: 1, gets: 1}); + return x; + }); + }); +} + +describe('In-memory caches', () => { + basicTests(() => new InMemoryCache(10)); + it('should give extra stats', () => { + const cache = new InMemoryCache(1); + cache.statString().should.equal( + '0 puts; 0 gets, 0 hits, 0 misses (0.00%), LRU has 0 item(s) totalling 0 bytes'); + }); + + it('should evict old objects', () => { + const cache = new InMemoryCache(1); + return cache.put('a key', 'a value', 'bob') + .then(() => { + const promises = []; + const oneK = "".padEnd(1024); + for (let i = 0; i < 1024; i++) { + promises.push(cache.put(`key${i}`, oneK)); + } + return Promise.all(promises); + }) + .then(() => { + return cache.get('a key').should.eventually.contain({hit: false}); + }); + }); +}); + +describe('Multi caches', () => { + basicTests(() => new MultiCache(new InMemoryCache(10), new InMemoryCache(20), new InMemoryCache(30))); + + it('should write through', () => { + const subCache1 = new InMemoryCache(1); + const subCache2 = new InMemoryCache(1); + const cache = new MultiCache(subCache1, subCache2); + return cache.put('a key', 'a value', 'bob') + .then(() => { + return Promise.all([ + cache.get('a key').should.eventually.eql({hit: true, data: new Buffer('a value')}), + subCache1.get('a key').should.eventually.eql({hit: true, data: new Buffer('a value')}), + subCache2.get('a key').should.eventually.eql({hit: true, data: new Buffer('a value')}) + ]); + }); + }); + + it('services from the first cache hit', () => { + const subCache1 = new InMemoryCache(1); + const subCache2 = new InMemoryCache(1); + // Set up caches with deliberately skew values for the same key. + subCache1.put('a key', 'cache1'); + subCache2.put('a key', 'cache2'); + const cache = new MultiCache(subCache1, subCache2); + return cache.get('a key').should.eventually.eql({hit: true, data: new Buffer('cache1')}) + .then((x) => { + subCache1.hits.should.equal(1); + subCache1.gets.should.equal(1); + subCache2.hits.should.equal(0); + subCache2.gets.should.equal(0); + return x; + }).then(() => { + Promise.all([ + subCache1.get('a key').should.eventually.eql({hit: true, data: new Buffer('cache1')}), + subCache2.get('a key').should.eventually.eql({hit: true, data: new Buffer('cache2')})] + ); + }); + }); +}); + +describe('On disk caches', () => { + basicTests(() => new OnDiskCache(newTempDir(), 10)); + it('should evict old objects', () => { + const tempDir = newTempDir(); + const cache = new OnDiskCache(tempDir, 1); + return cache.put('a key', 'a value', 'bob') + .then(() => { + const promises = []; + const oneK = "".padEnd(1024); + for (let i = 0; i < 1024; i++) { + promises.push(cache.put(`key${i}`, oneK)); + } + return Promise.all(promises); + }) + .then(() => { + return cache.get('a key').should.eventually.contain({hit: false}); + }); + }); + + it('should handle existing data', () => { + const tempDir = newTempDir(); + fs.writeFileSync(path.join(tempDir, 'abcdef'), 'this is abcdef'); + fs.mkdirSync(path.join(tempDir, 'path')); + fs.writeFileSync(path.join(tempDir, 'path', 'test'), 'this is path/test'); + const cache = new OnDiskCache(tempDir, 1); + return Promise.all([ + cache.get('abcdef').should.eventually.eql({hit: true, data: new Buffer('this is abcdef')}), + cache.get('path/test').should.eventually.eql({hit: true, data: new Buffer('this is path/test')})]); + }); + + // MRG ideally handle the case of pre-populated stuff overflowing the size + // and test sorting by mtime, but that might be too tricky. +}); + +const S3FS = {}; +AWS.mock('S3', 'getObject', (params, callback) => { + params.Bucket.should.equal("test.bucket"); + const result = S3FS[params.Key]; + if (!result) { + const error = new Error("Not found"); + error.code = "NoSuchKey"; + callback(error); + } else { + callback(null, {Body:result}); + } +}); +AWS.mock('S3', 'putObject', (params, callback) => { + params.Bucket.should.equal("test.bucket"); + S3FS[params.Key] = params.Body; + callback(null, {}); +}); +describe('S3 tests', () => { + basicTests(() => new S3Cache('test.bucket', 'cache', 'uk-north-1')); + // BE VERY CAREFUL - the below can be used with sufficient permissions to test on prod (With mocks off)... + // basicTests(() => new S3Cache('storage.godbolt.org', 'cache', 'us-east-1')); +}); \ No newline at end of file