Skip to content

add secondary address cache #159

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

Merged
merged 7 commits into from
Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions at_lookup/lib/src/cache/secondary_address_cache.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import 'package:at_commons/at_commons.dart';
import 'package:at_lookup/at_lookup.dart';
import 'package:at_lookup/src/util/lookup_util.dart';
import 'package:at_utils/at_logger.dart';

class SecondaryAddressCache {
static const Duration defaultCacheDuration = Duration(hours: 1);

final Map<String, SecondaryAddressCacheEntry> _map = {};
final _logger = AtSignLogger('SecondaryAddressCacheImpl');

final String _rootDomain;
final int _rootPort;
late SecondaryAddressFinder _secondaryFinder;

SecondaryAddressCache(this._rootDomain, this._rootPort, {SecondaryAddressFinder? secondaryFinder}) {
_secondaryFinder = secondaryFinder ?? SecondaryAddressFinder();
}

bool cacheContains(String atSign) {
return _map.containsKey(stripAtSignFromAtSign(atSign));
}

/// Returns the expiry time of this entry in millisecondsSinceEpoch, or null if this atSign is not in cache
int? getCacheExpiryTime(String atSign) {
atSign = stripAtSignFromAtSign(atSign);
if (_map.containsKey(atSign)) {
return _map[atSign]!.expiresAt;
} else {
return null;
}
}

Future<SecondaryAddress> getAddress(String atSign, {bool refreshCacheNow = false, Duration cacheFor = defaultCacheDuration}) async {
atSign = stripAtSignFromAtSign(atSign);

if (refreshCacheNow || _cacheIsEmptyOrExpired(atSign)) {
// _updateCache will either populate the cache, or throw an exception
await _updateCache(atSign, cacheFor);
}

if (_map.containsKey(atSign)) {
// should always be true, since _updateCache will throw an exception if it fails
return _map[atSign]!.secondaryAddress;
} else {
// but just in case, we'll throw an exception if it's not
throw Exception("Failed to find secondary, in a theoretically impossible way");
}
}

bool _cacheIsEmptyOrExpired(String atSign) {
if (_map.containsKey(atSign)) {
SecondaryAddressCacheEntry entry = _map[atSign]!;
if (entry.expiresAt < DateTime.now().millisecondsSinceEpoch) {
// expiresAt is in the past - cache has expired
return true;
} else {
// cache has not yet expired
return false;
}
} else {
// cache is empty
return true;
}
}

static String stripAtSignFromAtSign(String atSign) {
if (atSign.startsWith('@')) {
atSign = atSign.replaceFirst('@', '');
}
return atSign;
}
static String getNotFoundExceptionMessage(String atSign) {
return 'Unable to find secondary address for atSign:$atSign';
}
Future<void> _updateCache(String atSign, Duration cacheFor) async {
try {
String? secondaryUrl = await _secondaryFinder.findSecondary(atSign, _rootDomain, _rootPort);
if (secondaryUrl == null ||
secondaryUrl.isEmpty ||
secondaryUrl == 'data:null') {
throw SecondaryNotFoundException(getNotFoundExceptionMessage(atSign));
}
var secondaryInfo = LookUpUtil.getSecondaryInfo(secondaryUrl);
String secondaryHost = secondaryInfo[0];
int secondaryPort = int.parse(secondaryInfo[1]);

SecondaryAddress addr = SecondaryAddress(secondaryHost, secondaryPort);
_map[atSign] = SecondaryAddressCacheEntry(addr, DateTime.now().add(cacheFor).millisecondsSinceEpoch);
} on Exception catch (e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have been wondering more about this. One of the problems we have is when we throw an exception. So I think we should just return the cached value (even if it is stale, it's better than throwing an exception) - and perhaps for visibility, add another field to the CacheEntry like 'lookupException' which would be 'most recent cache refresh fail reason'; would be set when we catch an exception, would be cleared when a lookup succeeds.

_logger.severe('${getNotFoundExceptionMessage(atSign)} - ${e.toString()}');
rethrow;
}
}
}

class SecondaryAddress {
final String host;
final int port;
SecondaryAddress(this.host, this.port);

@override
String toString() {
return '$host:$port';
}
}

class SecondaryAddressCacheEntry {
final SecondaryAddress secondaryAddress;
/// milliseconds since epoch
final int expiresAt;

SecondaryAddressCacheEntry(this.secondaryAddress, this.expiresAt);
}

class SecondaryAddressFinder {
Future<String?> findSecondary(String atSign, String rootDomain, int rootPort) async {
return await AtLookupImpl.findSecondary(atSign, rootDomain, rootPort);
}
}
1 change: 1 addition & 0 deletions at_lookup/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies:
at_utils: ^3.0.6
at_commons: ^3.0.9
mutex: ^3.0.0
mocktail: ^0.3.0

#dependency_overrides:
# at_commons:
Expand Down
115 changes: 115 additions & 0 deletions at_lookup/test/secondary_address_cache_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import 'package:at_commons/at_commons.dart';
import 'package:at_lookup/src/cache/secondary_address_cache.dart';
import 'package:test/expect.dart';
import 'package:test/scaffolding.dart';
import 'package:mocktail/mocktail.dart';

class MockSecondaryFinder extends Mock implements SecondaryAddressFinder {}

void main() async {
String rootDomain = 'root.atsign.unit.tests';
int rootPort = 64;
SecondaryAddressFinder mockSecondaryFinder = MockSecondaryFinder();

String _addressFromAtSign(String atSign) {
if (atSign.startsWith('@')) {
atSign = atSign.replaceFirst('@', '');
}
return '$atSign.secondaries.unit.tests:1001';
}

group('this should be moved to functional tests', () {
test('look up @cicd1 from root.atsign.wtf:64', () async {
var secondaryAddress = await SecondaryAddressCache('root.atsign.wtf', 64)
.getAddress('@cicd1');
expect(secondaryAddress.port, isNotNull);
expect(secondaryAddress.host, isNotNull);
print(secondaryAddress.toString());
});
});

group('some cache tests', () {
late SecondaryAddressCache cache;

setUp(() {
reset(mockSecondaryFinder);
when(() => mockSecondaryFinder.findSecondary(
any(that: startsWith('registered')), rootDomain, rootPort))
.thenAnswer((invocation) async =>
_addressFromAtSign(invocation.positionalArguments.first));
when(() => mockSecondaryFinder.findSecondary(
any(that: startsWith('notCached')), rootDomain, rootPort))
.thenAnswer((invocation) async =>
_addressFromAtSign(invocation.positionalArguments.first));
when(() => mockSecondaryFinder.findSecondary(
any(that: startsWith('notRegistered')), rootDomain, rootPort))
.thenAnswer((invocation) async {
throw SecondaryNotFoundException(
SecondaryAddressCache.getNotFoundExceptionMessage(
invocation.positionalArguments.first));
});

cache = SecondaryAddressCache(rootDomain, rootPort,
secondaryFinder: mockSecondaryFinder);
});

test('test simple lookup for @registeredAtSign1', () async {
var atSign = '@registeredAtSign1';
var secondaryAddress = await cache.getAddress(atSign);
expect(secondaryAddress.port, isNotNull);
expect(secondaryAddress.host, isNotNull);
expect(secondaryAddress.toString(), _addressFromAtSign(atSign));
});
test('test simple lookup for registeredAtSign1', () async {
var atSign = 'registeredAtSign1';
var secondaryAddress = await cache.getAddress(atSign);
expect(secondaryAddress.port, isNotNull);
expect(secondaryAddress.host, isNotNull);
expect(secondaryAddress.toString(), _addressFromAtSign(atSign));
});
test('test simple lookup for notRegisteredAtSign1', () async {
var atSign = 'notRegisteredAtSign1';
expect(
() async => await cache.getAddress(atSign),
throwsA(predicate((e) =>
e is SecondaryNotFoundException &&
e.message ==
SecondaryAddressCache.getNotFoundExceptionMessage(atSign))));
});
test('test isCached for registeredAtSign1', () async {
var atSign = 'registeredAtSign1';
await cache.getAddress(atSign);
expect(cache.cacheContains(atSign), true);
});
test('test isCached for notRegisteredAtSign1', () async {
var atSign = 'notRegisteredAtSign1';
expect(cache.cacheContains(atSign), false);
});

test('test expiry time - default cache expiry for registeredAtSign1',
() async {
var atSign = 'registeredAtSign1';
await cache.getAddress(atSign);
final approxExpiry =
DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch;
expect(cache.getCacheExpiryTime(atSign), isNotNull);
expect((approxExpiry - cache.getCacheExpiryTime(atSign)!) < 100, true);
});

test('test expiry time - custom cache expiry for registeredAtSign1',
() async {
var atSign = 'registeredAtSign1';
await cache.getAddress(atSign, cacheFor: Duration(seconds: 30));
final approxExpiry =
DateTime.now().add(Duration(seconds: 30)).millisecondsSinceEpoch;
expect(cache.getCacheExpiryTime(atSign), isNotNull);
expect((approxExpiry - cache.getCacheExpiryTime(atSign)!) < 100, true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

});

test('test update cache for atsign which is not yet cached', () async {
var atSign = 'notCachedAtSign1';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe add an assertion that the atsign is not cached before calling cache.getAddress

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

await cache.getAddress(atSign, refreshCacheNow: true);
expect(cache.cacheContains(atSign), true);
});
});
}