From 5eb696c3e80c4d08eed61d609440bb2521ca584c Mon Sep 17 00:00:00 2001 From: Matthew Oslan Date: Wed, 3 Jul 2019 16:26:14 -0400 Subject: [PATCH] Cache calculated watering scale when using data from Dark Sky --- WateringScaleCache.ts | 69 +++++++++++++ package-lock.json | 69 +++++++------ package.json | 2 + routes/weather.ts | 115 +++++++++++++-------- routes/weatherProviders/DarkSky.ts | 4 + routes/weatherProviders/WeatherProvider.ts | 9 ++ 6 files changed, 192 insertions(+), 76 deletions(-) create mode 100644 WateringScaleCache.ts diff --git a/WateringScaleCache.ts b/WateringScaleCache.ts new file mode 100644 index 0000000..5452505 --- /dev/null +++ b/WateringScaleCache.ts @@ -0,0 +1,69 @@ +import * as NodeCache from "node-cache"; +import { GeoCoordinates, PWS } from "./types"; +import { AdjustmentOptions } from "./routes/adjustmentMethods/AdjustmentMethod"; +import * as moment from "moment-timezone"; +import * as geoTZ from "geo-tz"; +import { Moment } from "moment-timezone/moment-timezone"; + +export default class WateringScaleCache { + private readonly cache: NodeCache = new NodeCache(); + + /** + * Stores the results of a watering scale calculation. The scale will be cached until the end of the day in the local + * timezone of the specified coordinates. If a scale has already been cached for the specified calculation parameters, + * this method will have no effect. + * @param adjustmentMethodId The ID of the AdjustmentMethod used to calculate this watering scale. This value should + * have the appropriate bits set for any restrictions that were used. + * @param coordinates The coordinates the watering scale was calculated for. + * @param pws The PWS used to calculate the watering scale, or undefined if one was not used. + * @param adjustmentOptions Any user-specified adjustment options that were used when calculating the watering scale. + * @param wateringScale The results of the watering scale calculation. + */ + public storeWateringScale( + adjustmentMethodId: number, + coordinates: GeoCoordinates, + pws: PWS, + adjustmentOptions: AdjustmentOptions, + wateringScale: CachedScale + ): void { + // The end of the day in the controller's timezone. + const expirationDate: Moment = moment().tz( geoTZ( coordinates[ 0 ], coordinates[ 1 ] )[ 0 ] ).endOf( "day" ); + const ttl: number = ( expirationDate.unix() - moment().unix() ); + const key = this.makeKey( adjustmentMethodId, coordinates, pws, adjustmentOptions ); + this.cache.set( key, wateringScale, ttl ); + } + + /** + * Retrieves a cached scale that was previously calculated with the given parameters. + * @param adjustmentMethodId The ID of the AdjustmentMethod used to calculate this watering scale. This value should + * have the appropriate bits set for any restrictions that were used. + * @param coordinates The coordinates the watering scale was calculated for. + * @param pws The PWS used to calculate the watering scale, or undefined if one was not used. + * @param adjustmentOptions Any user-specified adjustment options that were used when calculating the watering scale. + * @return The cached result of the watering scale calculation, or undefined if no values were cached. + */ + public getWateringScale( + adjustmentMethodId: number, + coordinates: GeoCoordinates, + pws: PWS, + adjustmentOptions: AdjustmentOptions + ): CachedScale | undefined { + const key = this.makeKey( adjustmentMethodId, coordinates, pws, adjustmentOptions ); + return this.cache.get( key ); + } + + private makeKey( + adjustmentMethodId: number, + coordinates: GeoCoordinates, + pws: PWS, + adjustmentOptions: AdjustmentOptions + ): string { + return `${ adjustmentMethodId }#${ coordinates.join( "," ) }#${ pws ? pws.id : "" }#${ JSON.stringify( adjustmentOptions ) }` + } +} + +export interface CachedScale { + scale: number; + rawData: object; + rainDelay: number; +} diff --git a/package-lock.json b/package-lock.json index 322f555..042b6ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "os-weather-service", - "version": "1.0.2", + "version": "1.0.4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -127,6 +127,15 @@ "integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==", "dev": true }, + "@types/node-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/node-cache/-/node-cache-4.1.3.tgz", + "integrity": "sha512-3hsqnv3H1zkOhjygJaJUYmgz5+FcPO3vejBX7cE9/cnuINOJYrzkfOnUCvpwGe9kMZANIHJA7J5pOdeyv52OEw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/range-parser": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", @@ -571,6 +580,11 @@ "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=", "dev": true }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -1126,8 +1140,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -1148,14 +1161,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1170,20 +1181,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -1300,8 +1308,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -1313,7 +1320,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -1328,7 +1334,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1336,14 +1341,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -1362,7 +1365,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -1443,8 +1445,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -1456,7 +1457,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -1542,8 +1542,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -1579,7 +1578,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -1599,7 +1597,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -1643,14 +1640,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -2091,8 +2086,7 @@ "lodash": { "version": "4.17.11", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", - "dev": true + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, "lowercase-keys": { "version": "1.0.1", @@ -2465,6 +2459,15 @@ } } }, + "node-cache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-4.2.0.tgz", + "integrity": "sha512-obRu6/f7S024ysheAjoYFEEBqqDWv4LOMNJEuO8vMeEw2AT4z+NCzO4hlc2lhI4vATzbCQv6kke9FVdx0RbCOw==", + "requires": { + "clone": "2.x", + "lodash": "4.x" + } + }, "node-watch": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.6.2.tgz", diff --git a/package.json b/package.json index 0ce5cd2..fa2f69f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "geo-tz": "^5.0.4", "mockdate": "^2.0.2", "moment-timezone": "^0.5.25", + "node-cache": "^4.2.0", "suncalc": "^1.8.0" }, "devDependencies": { @@ -30,6 +31,7 @@ "@types/mocha": "^5.2.6", "@types/moment-timezone": "^0.5.12", "@types/node": "^10.14.6", + "@types/node-cache": "^4.1.3", "@types/suncalc": "^1.8.0", "chai": "^4.2.0", "mocha": "^5.2.0", diff --git a/routes/weather.ts b/routes/weather.ts index fe313e5..b41e00e 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -11,6 +11,7 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from ". import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; +import WateringScaleCache, { CachedScale } from "../WateringScaleCache"; const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )(); @@ -30,6 +31,8 @@ const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = { 2: RainDelayAdjustmentMethod }; +const cache = new WateringScaleCache(); + /** * Resolves a location description to geographic coordinates. * @param location A partial zip/city/country or a coordinate pair. @@ -227,57 +230,83 @@ export const getWateringData = async function( req: express.Request, res: expres } const weatherProvider = pws ? PWS_WEATHER_PROVIDER : WEATHER_PROVIDER; - let adjustmentMethodResponse: AdjustmentMethodResponse; - try { - adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( - adjustmentOptions, coordinates, weatherProvider, pws - ); - } catch ( err ) { - if ( typeof err != "string" ) { - /* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are - missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error - thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message - since it may contain sensitive information. */ - res.send( "Error: an unexpected error occurred." ); - console.error( `An unexpected error occurred for ${ req.url }: `, err ); - } else { - res.send( "Error: " + err ); - } - - return; - } - - let scale = adjustmentMethodResponse.scale; - - if ( checkRestrictions ) { - let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData; - // Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked. - if ( checkRestrictions && !wateringData ) { - try { - wateringData = await weatherProvider.getWateringData( coordinates ); - } catch ( err ) { - res.send( "Error: " + err ); - return; - } - } - - // Check for any user-set restrictions and change the scale to 0 if the criteria is met - if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { - scale = 0; - } - } const data = { - scale: scale, - rd: adjustmentMethodResponse.rainDelay, + scale: undefined, + rd: undefined, tz: getTimezone( timeData.timezone, undefined ), sunrise: timeData.sunrise, sunset: timeData.sunset, eip: ipToInt( remoteAddress ), - rawData: adjustmentMethodResponse.rawData, - error: adjustmentMethodResponse.errorMessage + rawData: undefined, + error: undefined }; + let cachedScale: CachedScale; + if ( weatherProvider.shouldCacheWateringScale() ) { + cachedScale = cache.getWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions ); + } + + if ( cachedScale ) { + // Use the cached data if it exists. + data.scale = cachedScale.scale; + data.rawData = cachedScale.rawData; + data.rd = cachedScale.rainDelay; + } else { + // Calculate the watering scale if it wasn't found in the cache. + let adjustmentMethodResponse: AdjustmentMethodResponse; + try { + adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( + adjustmentOptions, coordinates, weatherProvider, pws + ); + } catch ( err ) { + if ( typeof err != "string" ) { + /* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are + missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error + thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message + since it may contain sensitive information. */ + res.send( "Error: an unexpected error occurred." ); + console.error( `An unexpected error occurred for ${ req.url }: `, err ); + } else { + res.send( "Error: " + err ); + } + + return; + } + + data.scale = adjustmentMethodResponse.scale; + data.error = adjustmentMethodResponse.errorMessage; + data.rd = adjustmentMethodResponse.rainDelay; + data.rawData = adjustmentMethodResponse.rawData; + + if ( checkRestrictions ) { + let wateringData: BaseWateringData = adjustmentMethodResponse.wateringData; + // Fetch the watering data if the AdjustmentMethod didn't fetch it and restrictions are being checked. + if ( checkRestrictions && !wateringData ) { + try { + wateringData = await weatherProvider.getWateringData( coordinates ); + } catch ( err ) { + res.send( "Error: " + err ); + return; + } + } + + // Check for any user-set restrictions and change the scale to 0 if the criteria is met + if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) { + data.scale = 0; + } + } + + // Cache the watering scale if caching is enabled and no error occurred. + if ( weatherProvider.shouldCacheWateringScale() && !data.error ) { + cache.storeWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions, { + scale: data.scale, + rawData: data.rawData, + rainDelay: data.rd + } ); + } + } + // Return the response to the client in the requested format if ( outputFormat === "json" ) { res.json( data ); diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index a7228aa..d064bca 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -111,4 +111,8 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { return weather; } + + public shouldCacheWateringScale(): boolean { + return true; + } } diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index 895bbab..0667978 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -24,4 +24,13 @@ export class WeatherProvider { getWeatherData( coordinates : GeoCoordinates ): Promise< WeatherData > { throw "Selected WeatherProvider does not support getWeatherData"; } + + /** + * Returns a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached + * until the end of the day in timezone the data was for. + * @return a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached. + */ + shouldCacheWateringScale(): boolean { + return false; + } }