Skip to content

Commit

Permalink
Add tests for ExpiringValue, add comments
Browse files Browse the repository at this point in the history
  • Loading branch information
adam-fowler committed Jun 17, 2023
1 parent 9b0bd14 commit b0d9c88
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 6 deletions.
4 changes: 2 additions & 2 deletions Sources/SotoCore/AWSEndpointDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public struct AWSEndpoints {
public struct AWSEndpointStorage: Sendable {
let endpoint: ExpiringValue<String>

public init(initialValue: String) {
self.endpoint = .init(initialValue, threshold: 3 * 60)
public init() {
self.endpoint = .init(threshold: 3 * 60)
}

public func getValue(getExpiringValue: @escaping @Sendable () async throws -> (String, Date)) async throws -> String {
Expand Down
22 changes: 20 additions & 2 deletions Sources/SotoCore/Concurrency/ExpiringValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@
import Foundation
import Logging

/// Type holding a value and an expiration value.
///
/// When accessing the value you have to provide a closure that will update the
/// value if it has expired or is about to expire. The type ensures there is only
/// ever one value update running at any one time. If an update is already running
/// when you call `getValue` it will wait on the current update function to finish.
actor ExpiringValue<T> {
enum State {
/// No value is stored
case noValue
/// Waiting on a value to be generated
case waitingOnValue(Task<T, Error>)
/// Is holding a value
case withValue(T, Date)
/// Is holding a value, and there is a task in progress to update it
case withValueAndWaiting(T, Date, Task<T, Error>)
}

Expand All @@ -31,9 +41,9 @@ actor ExpiringValue<T> {
self.state = .noValue
}

init(_ initialValue: T, threshold: TimeInterval = 2) {
init(_ initialValue: T, expires: Date, threshold: TimeInterval = 2) {
self.threshold = threshold
self.state = .withValue(initialValue, Date.distantPast)
self.state = .withValue(initialValue, expires)
}

func getValue(getExpiringValue: @escaping @Sendable () async throws -> (T, Date)) async throws -> T {
Expand All @@ -48,10 +58,14 @@ actor ExpiringValue<T> {

case .withValue(let value, let expires):
if expires.timeIntervalSinceNow < 0 {
// value has expired, create new task to update value and
// return the result of that task
let task = self.getValueTask(getExpiringValue)
self.state = .waitingOnValue(task)
return try await task.value
} else if expires.timeIntervalSinceNow < self.threshold {
// value is about to expire, create new task to update value and
// return current value
let task = self.getValueTask(getExpiringValue)
self.state = .withValueAndWaiting(value, expires, task)
return value
Expand All @@ -61,13 +75,17 @@ actor ExpiringValue<T> {

case .withValueAndWaiting(let value, let expires, let task):
if expires.timeIntervalSinceNow < 0 {
// as value has expired wait for task to finish and return result
return try await task.value
} else {
// value hasn't expired so return current value
return value
}
}
}

/// Create task that will return a new version of the value and a date it will expire
/// - Parameter getExpiringValue: Function return value and expiration date
func getValueTask(_ getExpiringValue: @escaping @Sendable () async throws -> (T, Date)) -> Task<T, Error> {
return Task {
let (value, expires) = try await getExpiringValue()
Expand Down
90 changes: 90 additions & 0 deletions Tests/SotoCoreTests/Concurrency/ExpiringValueTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Soto for AWS open source project
//
// Copyright (c) 2023 the Soto project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Soto project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Atomics
@testable import SotoCore
import XCTest

final class ExpiringValueTests: XCTestCase {
/// Test value returned from closure is given back
func testValue() async throws {
let expiringValue = ExpiringValue<Int>()
let value = try await expiringValue.getValue {
try await Task.sleep(nanoseconds: 1000)
return (1, Date.now)
}
XCTAssertEqual(value, 1)
}

/// Test an expired value is updated
func testExpiredValue() async throws {
let expiringValue = ExpiringValue<Int>(0, expires: Date.now)
let value = try await expiringValue.getValue {
try await Task.sleep(nanoseconds: 1000)
return (1, Date.now)
}
XCTAssertEqual(value, 1)
}

/// Test when a value is just about to expire it returns current value and kicks off
/// new task to get new value
func testJustAboutToExpireValue() async throws {
let called = ManagedAtomic(false)
let expiringValue = ExpiringValue<Int>(0, expires: Date.now + 1, threshold: 3)
let value = try await expiringValue.getValue {
called.store(true, ordering: .relaxed)
try await Task.sleep(nanoseconds: 1000)
return (1, Date.now)
}
try await Task.sleep(nanoseconds: 10000)
// test it return current value
XCTAssertEqual(value, 0)
// test it kicked off a task
XCTAssertEqual(called.load(ordering: .relaxed), true)
}

/// Test closure is not called if value has not expired
func testClosureNotCalled() async throws {
let called = ManagedAtomic(false)
let expiringValue = ExpiringValue<Int>(0, expires: Date.distantFuture, threshold: 1)
let value = try await expiringValue.getValue {
called.store(true, ordering: .relaxed)
try await Task.sleep(nanoseconds: 1000)
return (1, Date.now)
}
XCTAssertEqual(value, 0)
XCTAssertEqual(called.load(ordering: .relaxed), false)
}

/// Test closure is only called once even though we asked for value 100 times
func testClosureCalledOnce() async throws {
let callCount = ManagedAtomic(0)
let expiringValue = ExpiringValue<Int>()
try await withThrowingTaskGroup(of: Int.self) { group in
for _ in 0..<100 {
group.addTask {
try await expiringValue.getValue {
callCount.wrappingIncrement(by: 1, ordering: .relaxed)
try await Task.sleep(nanoseconds: 100_000)
return (123, Date.distantFuture)
}
}
}
for try await result in group {
XCTAssertEqual(result, 123)
}
}
XCTAssertEqual(callCount.load(ordering: .relaxed), 1)
}
}
4 changes: 2 additions & 2 deletions Tests/SotoCoreTests/EndpointDiscoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ final class EndpointDiscoveryTests: XCTestCase {
from.getEndpointsCalledCount.load(ordering: .sequentiallyConsistent),
ordering: .sequentiallyConsistent
)
self.endpointStorage = AWSEndpointStorage(initialValue: self.config.endpoint)
self.endpointStorage = AWSEndpointStorage()
}

/// init
Expand All @@ -50,7 +50,7 @@ final class EndpointDiscoveryTests: XCTestCase {
endpoint: endpoint
)
self.endpointToDiscover = endpointToDiscover
self.endpointStorage = AWSEndpointStorage(initialValue: self.config.endpoint)
self.endpointStorage = AWSEndpointStorage()
}

struct TestRequest: AWSEncodableShape {}
Expand Down

0 comments on commit b0d9c88

Please sign in to comment.