-
Notifications
You must be signed in to change notification settings - Fork 8
Implement Time-To-Live (TTL) Support #23
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
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks really good. Some comments here and there
/// Update the TTL for a key in the Memcache server. | ||
/// | ||
/// - Parameters: | ||
/// - key: The key for which to update the TTL. | ||
/// - newTimeToLive: The new Time-To-Live value. If `nil`, the TTL will not be updated. | ||
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown or if there's an unexpected nil response. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Update the TTL for a key in the Memcache server. | |
/// | |
/// - Parameters: | |
/// - key: The key for which to update the TTL. | |
/// - newTimeToLive: The new Time-To-Live value. If `nil`, the TTL will not be updated. | |
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown or if there's an unexpected nil response. | |
/// Fetch the value and set its time-to-live for a key. | |
/// | |
/// - Parameters: | |
/// - key: The key to fetch the value and update the time-to-live for. | |
/// - newTimeToLive: The new time-to-live. If `nil`, the TTL will not be updated. | |
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown or if there's an unexpected nil response. |
/// Returns the duration in seconds between the current time and the expiration time. | ||
public func durationUntilExpiration(inRelationTo clock: ContinuousClock) -> UInt32 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should provide this. People can write this in their own code if they want to
/// - key: The key for which to update the TTL. | ||
/// - newTimeToLive: The new Time-To-Live value. If `nil`, the TTL will not be updated. | ||
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown or if there's an unexpected nil response. | ||
public func get<Value: MemcachedValue>(_ key: String, as valueType: Value.Type = Value.self, newTimeToLive: TimeToLive? = nil) async throws { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we taking an option TimeToLive
here? Isn't nil
= indefinitely
?
switch self.state { | ||
case .initial(_, _, _, let requestContinuation, let clock), | ||
.running(_, _, _, let requestContinuation, let clock): | ||
|
||
var flags = MemcachedFlags() | ||
if let newTimeToLive { | ||
flags = MemcachedFlags() | ||
flags.timeToLive = newTimeToLive.durationUntilExpiration(inRelationTo: clock) | ||
} | ||
flags.shouldReturnValue = true | ||
|
||
let command = MemcachedRequest.GetCommand(key: key, flags: flags) | ||
let request = MemcachedRequest.get(command) | ||
|
||
_ = try await withCheckedThrowingContinuation { continuation in | ||
switch requestContinuation.yield((request, continuation)) { | ||
case .enqueued: | ||
break | ||
case .dropped, .terminated: | ||
continuation.resume(throwing: MemcachedConnectionError.connectionShutdown) | ||
default: | ||
break | ||
} | ||
} | ||
|
||
case .finished: | ||
throw MemcachedConnectionError.connectionShutdown | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have been duplicating this code a bunch now. We should write a private
method that takes a MemcacheRequest
and does the continuation dance for us so that we just have to create the request and get the correct response.
/// Fetch the value for a key and its TTL from the Memcache server. | ||
/// | ||
/// - Parameter key: The key to fetch the value and TTL for. | ||
/// - Returns: A tuple containing the fetched value and its TTL, or `nil` if no value was found. | ||
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Fetch the value for a key and its TTL from the Memcache server. | |
/// | |
/// - Parameter key: The key to fetch the value and TTL for. | |
/// - Returns: A tuple containing the fetched value and its TTL, or `nil` if no value was found. | |
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown. | |
/// Fetch the value and its time-to-live for a key. | |
/// | |
/// - Parameter key: The key to fetch the value and time-to-live for. | |
/// - Returns: A tuple containing the fetched value and its TTL, or `nil` if no value was found. | |
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown. |
/// - Parameter key: The key to fetch the value and TTL for. | ||
/// - Returns: A tuple containing the fetched value and its TTL, or `nil` if no value was found. | ||
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown. | ||
public func get<Value: MemcachedValue>(_ key: String, as valueType: Value.Type = Value.self) async throws -> (Value?, TimeToLive?) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not a big fan of the tuple here. What about creating a new struct called ValueAndTimeToLive
. At the same time only that thing should be optional and not both. If we get a value we must get a TTL as well.
} | ||
} | ||
|
||
let value = Value.readFromBuffer(&response.value!) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This force unwrap is not safe
let ttl = response.flags?.timeToLive.map { | ||
TimeToLive.expiresAt(clock.now.advanced(by: Duration.seconds($0))) | ||
} ?? .indefinitely |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we get a flags.timeToLive
back even if there is no TTL set on the key?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I must have missed this case, as per the protocol
- t: return item TTL remaining in seconds (-1 for unlimited)
if no TTL is set it will default to -1.
/// If set, the value will expire after the specified TTL. | ||
/// If not set, the value will not expire. | ||
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown. | ||
public func set(_ key: String, value: some MemcachedValue, expiration: TimeToLive? = nil) async throws { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we make the expiration non optional and just default to indefinitely
.
if ttlIsUnlimited { | ||
// If TTL is negative, set it as indefinite. | ||
flags.timeToLive = nil | ||
ttlIsUnlimited = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do we differentiate the case when TTL is nil
because it is not present or when it is nil because it is infinite? I think we should probably store this a bit differently in the MemcacheFlags
struct
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In our MemcacheFlags
we can explore storing timeToLive as our TimeToLive
enum (instead of as a UInt32
), that way when TTL is present and indefinite we store it as flags.timeToLive = .indefinitely
and when TTL is present and has a expiry time we store it as flags.timeToLive = .expiresAt(instant)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That sounds good. We should probably store it as a TimeToLive?
public let value: Value | ||
/// The TTL of the fetched value. | ||
public let ttl: TimeToLive |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we make those var
s instead.
/// The value should never expire. | ||
case indefinitely | ||
/// The value should expire after a specified time. | ||
case expiresAt(ContinuousClock.Instant, (ContinuousClock.Instant, ContinuousClock) -> UInt32 = { _, _ in return 0 }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we have a closure here now?
if let timeToLive = flags.timeToLive { | ||
switch timeToLive { | ||
case .indefinitely: | ||
break |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we have to write something out here. In the case where the item has a TTL and we want to set it to indefinitely now. Please also write a test for this.
/// | ||
/// If true, the Time-To-Live (TTL) for the item is returned. | ||
/// If false, the TTL for the item is not returned. | ||
var shouldReturnTTL: Bool? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
var shouldReturnTTL: Bool? | |
var shouldReturnTimeToLive: Bool? |
/// The TTL of the fetched value. | ||
public var ttl: TimeToLive | ||
|
||
/// Initializes a new instance of `ValueAndTimeToLive` with a value and its TTL. | ||
public init(value: Value, ttl: TimeToLive) { | ||
self.value = value | ||
self.ttl = ttl |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// The TTL of the fetched value. | |
public var ttl: TimeToLive | |
/// Initializes a new instance of `ValueAndTimeToLive` with a value and its TTL. | |
public init(value: Value, ttl: TimeToLive) { | |
self.value = value | |
self.ttl = ttl | |
/// The time-to-live of the fetched value. | |
public var timeToLive: TimeToLive | |
/// Initializes a new instance of `ValueAndTimeToLive` with a value and its time-to-live. | |
public init(value: Value, timeToLive: TimeToLive) { | |
self.value = value | |
self.ttl = ttl |
/// - key: The key to update the time-to-live for. | ||
/// - newTimeToLive: The new time-to-live. | ||
/// - Throws: A `MemcachedConnectionError` if the connection is shutdown or if there's an unexpected nil response. | ||
public func touch<Value: MemcachedValue>(_ key: String, as valueType: Value.Type = Value.self, newTimeToLive: TimeToLive) async throws { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public func touch<Value: MemcachedValue>(_ key: String, as valueType: Value.Type = Value.self, newTimeToLive: TimeToLive) async throws { | |
public func touch(_ key: String, newTimeToLive: TimeToLive) async throws { |
/// If provided, the key-value pair will be removed from the cache after the specified TTL duration has passed. | ||
/// If not provided, the key-value pair will persist indefinitely in the cache. | ||
/// - Throws: A `MemcachedConnectionError` if the connection to the Memcached server is shut down. | ||
public func set(_ key: String, value: some MemcachedValue, expiration: TimeToLive = .indefinitely) async throws { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should be consistent here and name this parameter timeToLive
then.
/// Compares two `MemcachedFlags` instances for equality. | ||
/// | ||
/// Two `MemcachedFlags` instances are considered equal if they have the same `shouldReturnValue`, and `timeToLive` properties. | ||
/// | ||
/// - Parameters: | ||
/// - lhs: A `MemcachedFlags` instance. | ||
/// - rhs: Another `MemcachedFlags` instance. | ||
/// - Returns: `true` if the two instances are equal, `false` otherwise. | ||
static func == (lhs: MemcachedFlags, rhs: MemcachedFlags) -> Bool { | ||
guard lhs.shouldReturnValue == rhs.shouldReturnValue else { | ||
return false | ||
} | ||
switch (lhs.timeToLive, rhs.timeToLive) { | ||
case (.indefinitely?, .indefinitely?), (nil, nil): | ||
return true | ||
case (.expiresAt(let lhsInstant)?, .expiresAt(let rhsInstant)?): | ||
return lhsInstant == rhsInstant | ||
default: | ||
return false | ||
} | ||
} | ||
|
||
/// Provides a hash value for a `MemcachedFlags` instance. | ||
/// | ||
/// The hash value is composed from the `shouldReturnValue`, and `timeToLive` properties. | ||
/// | ||
/// - Parameter hasher: The hasher to use when combining the components of the instance. | ||
func hash(into hasher: inout Hasher) { | ||
hasher.combine(self.shouldReturnValue) | ||
switch self.timeToLive { | ||
case .indefinitely: | ||
hasher.combine("indefinitely") | ||
case .expiresAt(let instant): | ||
hasher.combine(instant) | ||
case .none: | ||
break | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think have to implement those. The compiler should be able to synthesise them.
let initialTTLValue = 5 | ||
let now = ContinuousClock.Instant.now | ||
let expirationTime = now.advanced(by: .seconds(initialTTLValue)) | ||
let expiration = TimeToLive.expiresAt(expirationTime) | ||
try await memcachedConnection.set("bar", value: setValue, expiration: expiration) | ||
|
||
// Update the TTL for the key to indefinite | ||
let newExpiration = TimeToLive.indefinitely | ||
_ = try await memcachedConnection.touch("bar", as: String.self, newTimeToLive: newExpiration) | ||
|
||
// Wait for more than the initial TTL duration | ||
// Sleep for 6 seconds | ||
try await Task.sleep(nanoseconds: UInt64(6 * 1_000_000_000)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use a shorter intial TTL here something like 1 second and then let's only sleep for 1.5 seconds after. Otherwise this test takes forever. Also please use Task.sleep(for:)
instead of the nanosecond variant.
/// The 'v' flag for the meta get command dictates whether the item value should be returned in the data block. | ||
/// The 'T' flag is used for both the meta get and meta set commands to specify the Time-To-Live (TTL) for an item. | ||
/// The 't' flag for the meta get command indicates whether the Time-To-Live (TTL) for the item should be returned. | ||
@available(macOS 13.0, *) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We always have to specify all platforms here. Take a look at the Clock availability and just copy it here and everywhere else. Maybe we should just bump our platform versions on Package.swift
instead though. That might be easier
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updating our Package.swift
!
let initialTTLValue = 60 * 60 * 24 * 30 + 5 // 30 days + 5 seconds | ||
let now = ContinuousClock.Instant.now | ||
let expirationTime = now.advanced(by: .seconds(initialTTLValue)) | ||
let expiration = TimeToLive.expiresAt(expirationTime) | ||
try await memcachedConnection.set("bar", value: setValue, expiration: expiration) | ||
|
||
// Wait for 6 seconds | ||
try await Task.sleep(nanoseconds: UInt64(6 * 1_000_000_000)) // Sleep for 6 seconds |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here. Please use a short TTL and use the other sleep method.
@@ -43,6 +44,29 @@ final class MemcachedRequestEncoderTests: XCTestCase { | |||
XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData) | |||
} | |||
|
|||
func testEncodeSetTTLRequest() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should have a test for indefinitely and for a very larger Instant as well (something larger than 30 days)
This PR addresses the need to enhance our Memcached functionality with
Time-To-Live
(TTL
) support.TTL
enables setting an expiration time one a key-value pair in the cache, automatically removing the data when it becomes stale. This PR will close #17.Motivation:
To improve our interactions with Memcached servers in a more dynamic and versatile way.
TTL
not only improves cache efficiently but also broadens our API capabilities.Modifications:
TimeToLive
enum that represents the Time-To-Live of aMemcachedValue
. This can be set toindefinitely
or toexpiresAt
. with the latter taking aContinuousClock.Instant
instant.timeToLive
property to theMemcachedFlags
struct allowing for TTL specifications in both ourget
andset
commands.touch
method in ourMemcachedConnection
that allows for updating theTTL
of an existing key without fetching it.set
method in ourMemcachedConnection
to include an optionalTimeToLive
parameter, this allows the user to set aTTL
for the key-value pair. If not set this will default to TTL being set toindefinitely
.Result:
With the addition of
TTL
support, our API now allows users to set an expiration for their key-value pair, improving cache efficiency and breading the capability of our API.