diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61c1c20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +lib/hoard.js +test/*.hoard diff --git a/README.md b/README.md index 5cf3cd0..d89f43e 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ hoard.update('users.hoard', 1337, 1311169605, function(err) { // Update multiple values at once in an existing Hoard file. // This function is much faster when dealing with multiple values // that need to be written at once. -hoard.update('users.hoard', [[1312490305, 4976], [1312492105, 3742]], function(err) { +hoard.updateMany('users.hoard', [[1312490305, 4976], [1312492105, 3742]], function(err) { if (err) throw err; console.log('Hoard file updated!'); }); @@ -102,6 +102,12 @@ maximum compatibility. They don't require the Python version to be installed but files generated by it. The tests were implemented using Expresso after some experimentation with Vows. Ran into some issues with Vows and decided to use the much simpler (and dumber) Expresso instead. +Testing +------- + + - [ cake setup ] + - cake [ install | build ] + - cake test Authors ------- diff --git a/package.json b/package.json index 5e9e317..6063647 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hoard", - "version": "0.1.5", + "version": "0.2.0", "description": "node.js lib for storing time series data on disk, similar to RRD.", "homepage": "https://github.com/cgbystrom/hoard", "author": "Carl Byström http://cgbystrom.com", @@ -17,8 +17,10 @@ "binary": ">= 0.2.5" }, "devDependencies": { - "coffee-script": ">= 1.0.1", - "expresso": ">= 0.8.1" + "coffee-script": ">= 1.0.1" + }, + "scripts": { + "prepublish" :"coffee -c -l -b -o lib src" }, "licenses": [{"type": "MIT", "url": "https://github.com/cgbystrom/hoard/raw/master/LICENSE"}] } diff --git a/src/hoard.coffee b/src/hoard.coffee index d156b88..396937e 100644 --- a/src/hoard.coffee +++ b/src/hoard.coffee @@ -1,3 +1,4 @@ +assert = require 'assert' fs = require 'fs' Buffer = require('buffer').Buffer Binary = require 'binary' @@ -25,28 +26,82 @@ metadataSize = pack.CalcLength(metadataFormat) archiveInfoFormat = "!3L" archiveInfoSize = pack.CalcLength(archiveInfoFormat) -unixTime = -> parseInt(new Date().getTime() / 1000) - -create = (filename, archives, xFilesFactor, cb) -> - # FIXME: Check parameters +# N.B. This rounds *up* rather than down a-la python-whisper +# I've done this just so that if hoard.js is used as the backend +# of statsd the timestamps it generates will never be *behind* those +# of statsd. (Am open to alternative approaches though...) +unixTime = -> Math.round(new Date().getTime() / 1000) + +aggregationTypeToMethod = + 1: 'average' + 2: 'sum' + 3: 'last' + 4: 'max' + 5: 'min' + +aggregationMethodToType = + 'average': 1 + 'sum' : 2 + 'last' : 3 + 'max' : 4 + 'min' : 5 + +aggregationTypes = ['average','sum','last','max','min'] + +sum = (list) -> + s = 0 + for x in list + s += x + s + +aggregate = (aggregationMethod, knownValues) -> + if aggregationMethod == 'average' + return sum(knownValues) / knownValues.length + else if aggregationMethod == 'sum' + return sum(knownValues) + else if aggregationMethod == 'last' + return knownValues[knownValues.length-1] + else if aggregationMethod == 'max' + return Math.max(knownValues) + else if aggregationMethod == 'min' + return Math.min(knownValues) + else + throw new Error( "Unrecognized aggregation method "+ aggregationMethod ) + +create = (filename, archives, xFilesFactor, aggregationMethod, cb) -> + + # Deal with allowing xFilesFactor and aggregationMethod to be optional (and defaulted) + if( cb == undefined && typeof(xFilesFactor) == 'function' ) + cb= xFilesFactor + xFilesFactor= 0.5 + aggregationMethod= 'average' + else if( cb == undefined && typeof(aggregationMethod) == 'function' ) + cb= aggregationMethod + aggregationMethod= 'average' + + if !( aggregationMethod in aggregationTypes) + cb new Error("'" + aggregationMethod + "' is not a valid aggregation method.") + return + + # FIXME: Check parameters # FIXME: Check that values are correctly formatted archives.sort (a, b) -> a[0] - b[0] if path.existsSync(filename) cb new Error('File ' + filename + ' already exists') - oldest = (a[0] * a[1] for a in archives).sort().reverse()[0] + oldest = (a[0] * a[1] for a in archives).sort((a) -> Number(a))[0] encodeFloat = (value) -> # Dirty hack. # Using 'buffer_ieee754' from node 0.5.x # as no libraries had a working IEEE754 encoder buffer = new Buffer(4) - require('buffer_ieee754').writeIEEE754(buffer, 0.5, 0, 'big', 23, 4); + require('../lib/buffer_ieee754').writeIEEE754(buffer, value, 0, 'big', 23, 4); buffer buffer = Put() - .word32be(unixTime()) # last update + .word32be(aggregationMethodToType[aggregationMethod]) .word32be(oldest) # max retention .put(encodeFloat(xFilesFactor)) .word32be(archives.length) @@ -68,54 +123,69 @@ create = (filename, archives, xFilesFactor, cb) -> # FIXME: fsync this? fs.writeFile filename, buffer.buffer(), 'binary', cb -propagate = (fd, timestamp, xff, higher, lower, cb) -> +propagate = (fd, header, timestamp, higher, lower, cb) -> + xff= header.xFilesFactor + aggregationMethod= header.aggregationMethod + lowerIntervalStart = timestamp - timestamp.mod(lower.secondsPerPoint) lowerIntervalEnd = lowerIntervalStart + lower.secondsPerPoint packedPoint = new Buffer(pointSize) - fs.read fd, packedPoint, 0, pointSize, higher.offset, (err, written, buffer) -> - cb(err) if err - [higherBaseInterval, higherBaseValue] = pack.Unpack(pointFormat, packedPoint) - - if higherBaseInterval == 0 - higherFirstOffset = higher.offset - else - timeDistance = lowerIntervalStart - higherBaseInterval - pointDistance = timeDistance / higher.secondsPerPoint - byteDistance = pointDistance * pointSize - higherFirstOffset = higher.offset + byteDistance.mod(higher.size) - - higherPoints = lower.secondsPerPoint / higher.secondsPerPoint - higherSize = higherPoints * pointSize - relativeFirstOffset = higherFirstOffset - higher.offset - relativeLastOffset = (relativeFirstOffset + higherSize).mod(higher.size) - higherLastOffset = relativeLastOffset + higher.offset - - if higherFirstOffset < higherLastOffset - # We don't wrap the archive - seriesSize = higherLastOffset - higherFirstOffset - seriesString = new Buffer(seriesSize) - - fs.read fd, seriesString, 0, seriesSize, higherFirstOffset, (err, written, buffer) -> - parseSeries(seriesString) - else - # We do wrap the archive - higherEnd = higher.offset + higher.size - firstSeriesSize = higherEnd - higherFirstOffset - secondSeriesSize = higherLastOffset - higher.offset - - seriesString = new Buffer(firstSeriesSize + secondSeriesSize) - - fs.read fd, seriesString, 0, firstSeriesSize, higherFirstOffset, (err, written, buffer) -> - cb(err) if err - if secondSeriesSize > 0 - fs.read fd, seriesString, firstSeriesSize, secondSeriesSize, higher.offset, (err, written, buffer) -> - cb(err) if err + try + fs.read fd, packedPoint, 0, pointSize, higher.offset, (err, written, buffer) -> + cb(err) if err + [higherBaseInterval, higherBaseValue] = pack.Unpack(pointFormat, packedPoint) + + if higherBaseInterval == 0 + higherFirstOffset = higher.offset + else + timeDistance = lowerIntervalStart - higherBaseInterval + pointDistance = timeDistance / higher.secondsPerPoint + byteDistance = pointDistance * pointSize + higherFirstOffset = higher.offset + byteDistance.mod(higher.size) + + higherPoints = lower.secondsPerPoint / higher.secondsPerPoint + higherSize = higherPoints * pointSize + relativeFirstOffset = higherFirstOffset - higher.offset + relativeLastOffset = (relativeFirstOffset + higherSize).mod(higher.size) + higherLastOffset = relativeLastOffset + higher.offset + + if higherFirstOffset < higherLastOffset + # We don't wrap the archive + seriesSize = higherLastOffset - higherFirstOffset + seriesString = new Buffer(seriesSize) + + try + fs.read fd, seriesString, 0, seriesSize, higherFirstOffset, (err, written, buffer) -> parseSeries(seriesString) - else - ret = new Buffer(firstSeriesSize) - seriesString.copy(ret, 0, 0, firstSeriesSize) - parseSeries(ret) + catch err + cb(err) + else + # We do wrap the archive + higherEnd = higher.offset + higher.size + firstSeriesSize = higherEnd - higherFirstOffset + secondSeriesSize = higherLastOffset - higher.offset + + seriesString = new Buffer(firstSeriesSize + secondSeriesSize) + + try + fs.read fd, seriesString, 0, firstSeriesSize, higherFirstOffset, (err, written, buffer) -> + cb(err) if err + if secondSeriesSize > 0 + try + fs.read fd, seriesString, firstSeriesSize, secondSeriesSize, higher.offset, (err, written, buffer) -> + cb(err) if err + parseSeries(seriesString) + catch err + cb(err) + else + ret = new Buffer(firstSeriesSize) + seriesString.copy(ret, 0, 0, firstSeriesSize) + parseSeries(ret) + catch err + cb(err) + catch err + cb(err) parseSeries = (seriesString) -> # Now we unpack the series data we just read @@ -144,36 +214,33 @@ propagate = (fd, timestamp, xff, higher, lower, cb) -> cb null, false return - sum = (list) -> - s = 0 - for x in list - s += x - s - knownPercent = knownValues.length / neighborValues.length if knownPercent >= xff # We have enough data to propagate a value! - aggregateValue = sum(knownValues) / knownValues.length # TODO: Another CF besides average? + aggregateValue = aggregate( aggregationMethod, knownValues ) myPackedPoint = pack.Pack(pointFormat, [lowerIntervalStart, aggregateValue]) # !!!!!!!!!!!!!!!!! packedPoint = new Buffer(pointSize) - fs.read fd, packedPoint, 0, pointSize, lower.offset, (err) -> - [lowerBaseInterval, lowerBaseValue] = pack.Unpack(pointFormat, packedPoint) - - if lowerBaseInterval == 0 - # First propagated update to this lower archive - offset = lower.offset - else - # Not our first propagated update to this lower archive - timeDistance = lowerIntervalStart - lowerBaseInterval - pointDistance = timeDistance / lower.secondsPerPoint - byteDistance = pointDistance * pointSize - offset = lower.offset + byteDistance.mod(lower.size) - - mypp = new Buffer(myPackedPoint) - fs.write fd, mypp, 0, pointSize, offset, (err) -> - cb(null, true) + try + fs.read fd, packedPoint, 0, pointSize, lower.offset, (err) -> + [lowerBaseInterval, lowerBaseValue] = pack.Unpack(pointFormat, packedPoint) + + if lowerBaseInterval == 0 + # First propagated update to this lower archive + offset = lower.offset + else + # Not our first propagated update to this lower archive + timeDistance = lowerIntervalStart - lowerBaseInterval + pointDistance = timeDistance / lower.secondsPerPoint + byteDistance = pointDistance * pointSize + offset = lower.offset + byteDistance.mod(lower.size) + + mypp = new Buffer(myPackedPoint) + fs.write fd, mypp, 0, pointSize, offset, (err) -> + cb(null, true) + catch err + cb(err) else cb(null, false) @@ -181,6 +248,8 @@ propagate = (fd, timestamp, xff, higher, lower, cb) -> update = (filename, value, timestamp, cb) -> # FIXME: Check file lock? # FIXME: Don't use info(), re-use fd between internal functions + # FIXME: Don't carry on after an error callback! + info filename, (err, header) -> cb(err) if err now = unixTime() @@ -204,38 +273,60 @@ update = (filename, value, timestamp, cb) -> myPackedPoint = new Buffer(pack.Pack(pointFormat, [myInterval, value])) packedPoint = new Buffer(pointSize) - fs.read fd, packedPoint, 0, pointSize, archive.offset, (err, bytesRead, buffer) -> - cb(err) if err - [baseInterval, baseValue] = pack.Unpack(pointFormat, packedPoint) - - if baseInterval == 0 - # This file's first update - fs.write fd, myPackedPoint, 0, pointSize, archive.offset, (err, written, buffer) -> - cb(err) if err - [baseInterval, baseValue] = [myInterval, value] - propagateLowerArchives() - else - # File has been updated before - timeDistance = myInterval - baseInterval - pointDistance = timeDistance / archive.secondsPerPoint - byteDistance = pointDistance * pointSize - myOffset = archive.offset + byteDistance.mod(archive.size) - fs.write fd, myPackedPoint, 0, pointSize, myOffset, (err, written, buffer) -> - cb(err) if err - propagateLowerArchives() - + propagateLowerArchives = -> - # Propagate the update to lower-precision archives - #higher = archive - #for lower in lowerArchives: - # if not __propagate(fd, myInterval, header.xFilesFactor, higher, lower): - # break - # higher = lower - - #__changeLastUpdate(fh) + # complete hack (not proud of this), copied updateManyArchive's code for just one update. + alignedPoints = [ [ timestamp, value ] ] + # Now we propagate the updates to lower-precision archives + higher = archive + lowerArchives = (arc for arc in header.archives when arc.secondsPerPoint > archive.secondsPerPoint) + + if lowerArchives.length > 0 + # Collect a list of propagation calls to make + # This is easier than doing async looping + propagateCalls = [] + for lower in lowerArchives + fit = (i) -> i - i.mod(lower.secondsPerPoint) + lowerIntervals = (fit(p[0]) for p in alignedPoints) + uniqueLowerIntervals = _.uniq(lowerIntervals) + for interval in uniqueLowerIntervals + propagateCalls.push {interval: interval, header: header, higher: higher, lower: lower} + higher = lower + + callPropagate = (args, callback) -> + propagate fd, args.header, args.interval, args.higher, args.lower, (err, result) -> + cb err if err + callback err, result + + async.forEachSeries propagateCalls, callPropagate, (err, result) -> + cb err if err + fs.close fd, cb + else + fs.close fd, cb + + try + fs.read fd, packedPoint, 0, pointSize, archive.offset, (err, bytesRead, buffer) -> + cb(err) if err + [baseInterval, baseValue] = pack.Unpack(pointFormat, packedPoint) + + if baseInterval == 0 + # This file's first update + fs.write fd, myPackedPoint, 0, pointSize, archive.offset, (err, written, buffer) -> + cb(err) if err + [baseInterval, baseValue] = [myInterval, value] + propagateLowerArchives() + else + # File has been updated before + timeDistance = myInterval - baseInterval + pointDistance = timeDistance / archive.secondsPerPoint + byteDistance = pointDistance * pointSize + myOffset = archive.offset + byteDistance.mod(archive.size) + fs.write fd, myPackedPoint, 0, pointSize, myOffset, (err, written, buffer) -> + cb(err) if err + propagateLowerArchives() + catch err + cb(err) - # FIXME: Also fsync here? - fs.close fd, cb return updateMany = (filename, points, cb) -> @@ -277,12 +368,12 @@ updateMany = (filename, points, cb) -> currentPoints.push(point) async.series updateArchiveCalls, (err, results) -> - throw err if err + cb err if err if currentArchive and currentPoints.length > 0 # Don't forget to commit after we've checked all the archives currentPoints.reverse() updateManyArchive fd, header, currentArchive, currentPoints, (err) -> - throw err if err + cb err if err fs.close fd, cb else fs.close fd, cb @@ -308,7 +399,7 @@ updateManyArchive = (fd, header, archive, points, cb) -> [interval, value] = ap if !previousInterval or (interval == previousInterval + step) - currentString.concat(pack.Pack(pointFormat, [interval, value])) + currentString= currentString.concat(pack.Pack(pointFormat, [interval, value])) previousInterval = interval else numberOfPoints = currentString.length / pointSize @@ -324,70 +415,73 @@ updateManyArchive = (fd, header, archive, points, cb) -> # Read base point and determine where our writes will start packedBasePoint = new Buffer(pointSize) - fs.read fd, packedBasePoint, 0, pointSize, archive.offset, (err) -> - cb err if err - [baseInterval, baseValue] = pack.Unpack(pointFormat, packedBasePoint) - - if baseInterval == 0 - # This file's first update - # Use our first string as the base, so we start at the start - baseInterval = packedStrings[0][0] - - # Write all of our packed strings in locations determined by the baseInterval - - writePackedString = (ps, callback) -> - [interval, packedString] = ps - timeDistance = interval - baseInterval - pointDistance = timeDistance / step - byteDistance = pointDistance * pointSize - myOffset = archive.offset + byteDistance.mod(archive.size) - archiveEnd = archive.offset + archive.size - bytesBeyond = (myOffset + packedString.length) - archiveEnd - - if bytesBeyond > 0 - fs.write fd, packedString, 0, packedString.length - bytesBeyond, myOffset, (err) -> - cb err if err - assert.equal archiveEnd, myOffset + packedString.length - bytesBeyond - #assert fh.tell() == archiveEnd, "archiveEnd=%d fh.tell=%d bytesBeyond=%d len(packedString)=%d" % (archiveEnd,fh.tell(),bytesBeyond,len(packedString)) - # Safe because it can't exceed the archive (retention checking logic above) - fs.write fd, packedString, packedString.length - bytesBeyond, bytesBeyond, archive.offset, (err) -> + try + fs.read fd, packedBasePoint, 0, pointSize, archive.offset, (err) -> + cb err if err + [baseInterval, baseValue] = pack.Unpack(pointFormat, packedBasePoint) + + if baseInterval == 0 + # This file's first update + # Use our first string as the base, so we start at the start + baseInterval = packedStrings[0][0] + + # Write all of our packed strings in locations determined by the baseInterval + + writePackedString = (ps, callback) -> + [interval, packedString] = ps + timeDistance = interval - baseInterval + pointDistance = timeDistance / step + byteDistance = pointDistance * pointSize + myOffset = archive.offset + byteDistance.mod(archive.size) + archiveEnd = archive.offset + archive.size + bytesBeyond = (myOffset + packedString.length) - archiveEnd + + if bytesBeyond > 0 + fs.write fd, packedString, 0, packedString.length - bytesBeyond, myOffset, (err) -> cb err if err + assert.equal archiveEnd, myOffset + packedString.length - bytesBeyond + #assert fh.tell() == archiveEnd, "archiveEnd=%d fh.tell=%d bytesBeyond=%d len(packedString)=%d" % (archiveEnd,fh.tell(),bytesBeyond,len(packedString)) + # Safe because it can't exceed the archive (retention checking logic above) + fs.write fd, packedString, packedString.length - bytesBeyond, bytesBeyond, archive.offset, (err) -> + cb err if err + callback() + else + fs.write fd, packedString, 0, packedString.length, myOffset, (err) -> callback() - else - fs.write fd, packedString, 0, packedString.length, myOffset, (err) -> - callback() - - async.forEachSeries packedStrings, writePackedString, (err) -> - throw err if err - propagateLowerArchives() - - propagateLowerArchives = -> - # Now we propagate the updates to lower-precision archives - higher = archive - lowerArchives = (arc for arc in header.archives when arc.secondsPerPoint > archive.secondsPerPoint) - - if lowerArchives.length > 0 - # Collect a list of propagation calls to make - # This is easier than doing async looping - propagateCalls = [] - for lower in lowerArchives - fit = (i) -> i - i.mod(lower.secondsPerPoint) - lowerIntervals = (fit(p[0]) for p in alignedPoints) - uniqueLowerIntervals = _.uniq(lowerIntervals) - for interval in uniqueLowerIntervals - propagateCalls.push {interval: interval, header: header, higher: higher, lower: lower} - higher = lower - - callPropagate = (args, callback) -> - propagate fd, args.interval, args.header.xFilesFactor, args.higher, args.lower, (err, result) -> + + propagateLowerArchives = -> + # Now we propagate the updates to lower-precision archives + higher = archive + lowerArchives = (arc for arc in header.archives when arc.secondsPerPoint > archive.secondsPerPoint) + + if lowerArchives.length > 0 + # Collect a list of propagation calls to make + # This is easier than doing async looping + propagateCalls = [] + for lower in lowerArchives + fit = (i) -> i - i.mod(lower.secondsPerPoint) + lowerIntervals = (fit(p[0]) for p in alignedPoints) + uniqueLowerIntervals = _.uniq(lowerIntervals) + for interval in uniqueLowerIntervals + propagateCalls.push {interval: interval, header: header, higher: higher, lower: lower} + higher = lower + + callPropagate = (args, callback) -> + propagate fd, args.header, args.interval, args.higher, args.lower, (err, result) -> + cb err if err + callback err, result + + async.forEachSeries propagateCalls, callPropagate, (err, result) -> cb err if err - callback err, result - - async.forEachSeries propagateCalls, callPropagate, (err, result) -> - throw err if err + cb null + else cb null - else - cb null + + async.forEachSeries packedStrings, writePackedString, (err) -> + cb err if err + propagateLowerArchives() + catch err + cb(err) info = (path, cb) -> # FIXME: Close this stream? @@ -401,13 +495,17 @@ info = (path, cb) -> archives = []; metadata = {} Binary.parse(data) - .word32bu('lastUpdate') + .word32bu('aggregationMethod') .word32bu('maxRetention') .buffer('xff', 4) # Must decode separately since node-binary can't handle floats .word32bu('archiveCount') .tap (vars) -> metadata = vars metadata.xff = pack.Unpack('!f', vars.xff, 0)[0] + if aggregationTypeToMethod[metadata.aggregationMethod] != undefined + metadata.aggregationMethod = aggregationTypeToMethod[metadata.aggregationMethod] + else + metadata.aggregationMethod = 'average' @flush() for index in [0...metadata.archiveCount] @word32bu('offset').word32bu('secondsPerPoint').word32bu('points') @@ -421,6 +519,7 @@ info = (path, cb) -> maxRetention: metadata.maxRetention xFilesFactor: metadata.xff archives: archives + aggregationMethod: metadata.aggregationMethod return fetch = (path, from, to, cb) -> @@ -428,7 +527,7 @@ fetch = (path, from, to, cb) -> now = unixTime() oldestTime = now - header.maxRetention from = oldestTime if from < oldestTime - throw new Error('Invalid time interval') unless from < to + cb(new Error('Invalid time interval')) unless from < to to = now if to > now or to < from diff = now - from fd = null @@ -467,28 +566,37 @@ fetch = (path, from, to, cb) -> toOffset = getOffset(toInterval) fs.open path, 'r', (err, fd) -> - if err then throw err + cb err if err if fromOffset < toOffset # We don't wrap around, can everything in a single read size = toOffset - fromOffset seriesBuffer = new Buffer(size) - fs.read fd, seriesBuffer, 0, size, fromOffset, (err, num) -> - cb(err) if err - fs.close fd, (err) -> + try + fs.read fd, seriesBuffer, 0, size, fromOffset, (err, num) -> cb(err) if err - unpack(seriesBuffer) # We have read it, go unpack! + fs.close fd, (err) -> + cb(err) if err + unpack(seriesBuffer) # We have read it, go unpack! + catch err + cb(err) else # We wrap around the archive, we need two reads archiveEnd = archive.offset + archive.size size1 = archiveEnd - fromOffset size2 = toOffset - archive.offset seriesBuffer = new Buffer(size1 + size2) - fs.read fd, seriesBuffer, 0, size1, fromOffset, (err, num) -> - cb(err) if err - fs.read fd, seriesBuffer, size1, size2, archive.offset, (err, num) -> + try + fs.read fd, seriesBuffer, 0, size1, fromOffset, (err, num) -> cb(err) if err - unpack(seriesBuffer) # We have read it, go unpack! - fs.close(fd) + try + fs.read fd, seriesBuffer, size1, size2, archive.offset, (err, num) -> + cb(err) if err + unpack(seriesBuffer) # We have read it, go unpack! + fs.close(fd) + catch err + cb(err) + catch err + cb(err) unpack = (seriesData) -> # Optmize this? diff --git a/test/hoard.test.coffee b/test/hoard.test.coffee index acf4d7d..a4c1a73 100644 --- a/test/hoard.test.coffee +++ b/test/hoard.test.coffee @@ -1,7 +1,7 @@ assert = require 'assert' fs = require 'fs' path = require 'path' -hoard = require "hoard" +hoard = require "../lib/hoard" equal = assert.equal FILENAME = 'test/large.whisper'