Skip to content

Commit

Permalink
Merge pull request #74 from Workiva/DOCPLAT-1525
Browse files Browse the repository at this point in the history
DOCPLAT-1525 Create least recently used caching strategy
  • Loading branch information
Rosie the Robot authored Jan 2, 2018
2 parents f4da0ff + d6d06a3 commit 15b87f1
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 15 deletions.
2 changes: 2 additions & 0 deletions lib/cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@

export 'package:w_common/src/common/cache/cache.dart'
show Cache, CacheContext, CachingStrategy;
export 'package:w_common/src/common/cache/least_recently_used_strategy.dart'
show LeastRecentlyUsedStrategy;
export 'package:w_common/src/common/cache/reference_counting_strategy.dart'
show ReferenceCountingStrategy;
5 changes: 3 additions & 2 deletions lib/src/common/cache/cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ class CachingStrategy<TIdentifier, TValue> {
/// var release = cache.release('id');
/// var b = cache.getAsync('id', _superLongAsyncCall);
///
/// await release; // _superLongAsyncCall completes
/// await release;
/// // _superLongAsyncCall and onDidRelease have now completed
///
/// var c = cache.getAsync('id', _superLongAsyncCall);
Future<Null> onDidRelease(TIdentifier id, TValue value,
Expand Down Expand Up @@ -279,8 +280,8 @@ class Cache<TIdentifier, TValue> extends Object with Disposable {
Future<Null> remove(TIdentifier id) {
_throwWhenDisposed('remove');
if (_cache.containsKey(id)) {
final removedValue = _cache.remove(id);
_cachingStrategy.onWillRemove(id);
final removedValue = _cache.remove(id);
return removedValue.then((TValue value) async {
await _cachingStrategy.onDidRemove(id, value);
_didRemoveController.add(new CacheContext(id, value));
Expand Down
61 changes: 61 additions & 0 deletions lib/src/common/cache/least_recently_used_strategy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'dart:async';
import 'dart:collection';

import 'package:w_common/cache.dart';

/// A [CachingStrategy] that will hold the last n most recently used [TValue]s.
///
/// When n = 0 the strategy will remove a [TIdentifier] [TValue] pair immediately
/// on release.
class LeastRecentlyUsedStrategy<TIdentifier, TValue>
extends CachingStrategy<TIdentifier, TValue> {
/// [TIdentifier]s that have been released but not yet removed in order of most
/// to least recently used.
final Queue<TIdentifier> _removalQueue = new Queue<TIdentifier>();

/// The number of recently used [TIdentifier] [TValue] pairs to keep in the
/// cache before removing the least recently used pair from the cache.
final int _keep;

LeastRecentlyUsedStrategy(this._keep) {
if (_keep < 0) {
throw new ArgumentError(
'Cannot keep a negative number of most recently used items in the cache');
}
}

@override
Future<Null> onDidRelease(
TIdentifier id, TValue value, Future<Null> remove(TIdentifier id)) async {
// If there are more than _keep items in the queue remove the least recently
// used.
while (_removalQueue.length > _keep) {
await remove(_removalQueue.removeLast());
}
}

@override
void onWillGet(TIdentifier id) {
// A get has been called for id, removing it is now unnecessary.
_removalQueue.remove(id);
}

@override
void onWillRelease(TIdentifier id) {
// id has been released, add it to the front of the removal queue. If
// necessary, the least recently used items will be removed in onDidRemove
// which the cache will call after any pending async value factory
// associated with id completes. Items are added to the removal queue in
// onWillRelease rather than onDidRelease to allow a get called before an
// async value factory completes to cancel an unnecessary removal.
if (!_removalQueue.contains(id)) {
_removalQueue.addFirst(id);
}
}

@override
void onWillRemove(TIdentifier id) {
// id will be removed, removing it again is unnecessary.
_removalQueue.remove(id);
}
}
48 changes: 35 additions & 13 deletions test/unit/browser/cache/caching_strategy_common_test.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import 'dart:async';

import 'package:test/test.dart';
import 'package:w_common/func.dart';
import 'package:w_common/src/common/cache/cache.dart';
import 'package:w_common/src/common/cache/least_recently_used_strategy.dart';
import 'package:w_common/src/common/cache/reference_counting_strategy.dart';

typedef CachingStrategy<String, Object> CachingStrategyFactory();
Expand All @@ -10,22 +12,28 @@ typedef CachingStrategy<String, Object> CachingStrategyFactory();
void main() {
<String, CachingStrategyFactory>{
'ReferenceCountingStrategy': () =>
new ReferenceCountingStrategy<String, Object>()
new ReferenceCountingStrategy<String, Object>(),
'MostRecentlyUsedStrategy keep = 0': () => new LeastRecentlyUsedStrategy(0),
'MostRecentlyUsedStrategy keep = 1': () => new LeastRecentlyUsedStrategy(1),
'MostRecentlyUsedStrategy keep = 2': () => new LeastRecentlyUsedStrategy(2),
}.forEach((name, strategyFactory) {
group('$name', () {
Cache<String, Object> cache;
Func<Future<Object>> valueFactory;
int valueFactoryCalled;

setUp(() {
valueFactoryCalled = 0;
valueFactory = () async {
valueFactoryCalled++;
return new Object();
};
cache = new Cache(strategyFactory());
});

test(
'synchronous get release get should not unnecessarily '
'remove item from cache', () async {
var valueFactoryCalled = 0;
var valueFactory = () async {
valueFactoryCalled++;
return new Object();
};
var firstGetCall = cache.getAsync('id', valueFactory);
var release = cache.release('id');
var secondGetCall = cache.getAsync('id', valueFactory);
Expand All @@ -34,19 +42,15 @@ void main() {

var thirdGetCall = cache.getAsync('id', valueFactory);

expect(valueFactoryCalled, 1);
expect(await firstGetCall, await secondGetCall);
expect(await secondGetCall, await thirdGetCall);
// This should be checked after gets and releases are awaited
expect(valueFactoryCalled, 1);
});

test(
'synchronous get release release get should not unnecessarily '
'remove item from cache', () async {
var valueFactoryCalled = 0;
var valueFactory = () async {
valueFactoryCalled++;
return new Object();
};
var firstGetCall = cache.getAsync('id', valueFactory);
var releases = Future.wait([cache.release('id'), cache.release('id')]);
var secondGetCall = cache.getAsync('id', valueFactory);
Expand All @@ -55,9 +59,27 @@ void main() {

var thirdGetCall = cache.getAsync('id', valueFactory);

expect(valueFactoryCalled, 1);
expect(await firstGetCall, await secondGetCall);
expect(await secondGetCall, await thirdGetCall);
// This should be checked after gets and releases are awaited
expect(valueFactoryCalled, 1);
});

test(
'synchronous get remove get should result in value factory being called twice',
() async {
var firstGetCall = cache.getAsync('id', valueFactory);
var remove = cache.remove('id');
var secondGetCall = cache.getAsync('id', valueFactory);

await remove;

var thirdGetCall = cache.getAsync('id', valueFactory);

expect(await firstGetCall, isNot(await secondGetCall));
expect(await firstGetCall, isNot(await thirdGetCall));
// This should be checked after gets and releases are awaited
expect(valueFactoryCalled, 2);
});
});
});
Expand Down
71 changes: 71 additions & 0 deletions test/unit/browser/cache/least_recently_used_strategy_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'dart:async';

import 'package:test/test.dart';
import 'package:w_common/src/common/cache/cache.dart';
import 'package:w_common/src/common/cache/least_recently_used_strategy.dart';

void main() {
group('MostRecentlyUsedStrategy', () {
var expectedId = 'expectedId';
var expectedValue = 'expectedValue';

for (var i in new Iterable<int>.generate(3)) {
Cache<String, Object> cache;

setUp(() async {
cache = new Cache<String, Object>(new LeastRecentlyUsedStrategy(i));

// install expected item
await cache.get(expectedId, () => expectedValue);

// install i items into cache
for (var j in new Iterable<int>.generate(i)) {
await cache.get('$j', () => j);
}
});

test(
'release should remove released item after $i additional releases '
'when storing $i most recently used items', () async {
cache.didRemove.listen(expectAsync1((context) {
expect(context.id, expectedId);
expect(context.value, expectedValue);
}));

// release expected item
await cache.release(expectedId);

// create i releases, after which expected item (and only expected
// item) should be released
for (var j in new Iterable<int>.generate(i)) {
await cache.release('$j');
}
});

test(
'release after a synchronous getAsync remove getAsync call should '
'remove released item after $i additional releases when storing $i '
'most recently used items', () async {
cache.didRemove.listen(expectAsync1((context) {
expect(context.id, expectedId);
expect(context.value, expectedValue);
}, count: 2));

var firstGet = cache.getAsync(expectedId, () async => expectedValue);
var remove = cache.remove(expectedId);
var secondGet = cache.getAsync(expectedId, () async => expectedValue);

// release expected item
var release = cache.release(expectedId);

await Future.wait([firstGet, remove, secondGet, release]);

// create i releases, after which expected item (and only expected
// item) should be released
for (var j in new Iterable<int>.generate(i)) {
await cache.release('$j');
}
});
}
});
}
2 changes: 2 additions & 0 deletions test/unit/browser/generated_browser_tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ library test.unit.browser.generated_browser_tests;

import './cache/cache_test.dart' as cache_cache_test;
import './cache/caching_strategy_common_test.dart' as cache_caching_strategy_common_test;
import './cache/least_recently_used_strategy_test.dart' as cache_least_recently_used_strategy_test;
import './cache/reference_counting_strategy_test.dart' as cache_reference_counting_strategy_test;
import './disposable_browser_test.dart' as disposable_browser_test;
import './invalidation_mixin_test.dart' as invalidation_mixin_test;
Expand All @@ -14,6 +15,7 @@ import 'package:test/test.dart';
void main() {
cache_cache_test.main();
cache_caching_strategy_common_test.main();
cache_least_recently_used_strategy_test.main();
cache_reference_counting_strategy_test.main();
disposable_browser_test.main();
invalidation_mixin_test.main();
Expand Down

0 comments on commit 15b87f1

Please sign in to comment.