Skip to content
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

Some improvements to throttling #3079

Merged
merged 2 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "d8340521e532cffdf75a64468ff9362de8bd2bb9",
"version" : "1.2.3"
"revision" : "d3ab98dc2887d1cc3bed676f6fa354da4cb22b3c",
"version" : "1.2.4"
}
},
{
Expand Down Expand Up @@ -158,8 +158,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swiftui-navigation.git",
"state" : {
"revision" : "7ab04c6e2e6a73d34d5a762970ef88bf0aedb084",
"version" : "1.4.0"
"revision" : "b7c9a79f6f6b1fefb87d3e5a83a9c2fe7cdc9720",
"version" : "1.5.0"
}
},
{
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "2481e39ea43e14556ca9628259fa6b377427730c",
"version" : "1.0.1"
"revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b",
"version" : "1.1.0"
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ public final class FileStorageKey<Value: Codable & Sendable>: PersistenceKey, Se
private let storage: FileStorage
private let isSetting = LockIsolated(false)
private let url: URL
private let value = LockIsolated<Value?>(nil)
private let workItem = LockIsolated<DispatchWorkItem?>(nil)
fileprivate let state = LockIsolated(State())
// private let value = LockIsolated<Value?>(nil)
// private let workItem = LockIsolated<DispatchWorkItem?>(nil)

fileprivate struct State {
var value: Value?
var workItem: DispatchWorkItem?
}

public var id: AnyHashable {
FileStorageKeyID(url: self.url, storage: self.storage)
Expand All @@ -42,28 +48,32 @@ public final class FileStorageKey<Value: Codable & Sendable>: PersistenceKey, Se
}

public func save(_ value: Value) {
if self.workItem.value == nil {
self.isSetting.setValue(true)
try? self.storage.save(JSONEncoder().encode(value), self.url)
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
defer {
self.value.setValue(nil)
self.workItem.setValue(nil)
}
guard let value = self.value.value
else { return }
self.state.withValue { state in
if state.workItem == nil {
self.isSetting.setValue(true)
try? self.storage.save(JSONEncoder().encode(value), self.url)
}
self.workItem.setValue(workItem)
if canListenForResignActive {
self.storage.asyncAfter(.seconds(1), workItem)
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.state.withValue { state in
defer {
state.value = nil
state.workItem = nil
}
guard let value = state.value
else { return }
self.isSetting.setValue(true)
try? self.storage.save(JSONEncoder().encode(value), self.url)
}
}
state.workItem = workItem
if canListenForResignActive {
self.storage.asyncAfter(.seconds(1), workItem)
} else {
self.storage.async(workItem)
}
} else {
self.storage.async(workItem)
state.value = value
}
} else {
self.value.setValue(value)
}
}

Expand All @@ -82,17 +92,21 @@ public final class FileStorageKey<Value: Codable & Sendable>: PersistenceKey, Se
try? self.storage.save(Data(), self.url)
}
let writeCancellable = self.storage.fileSystemSource(self.url, [.write]) {
if self.isSetting.value == true {
self.isSetting.setValue(false)
} else {
self.workItem.withValue {
$0?.cancel()
$0 = nil
self.state.withValue { state in
if self.isSetting.value == true {
self.isSetting.setValue(false)
} else {
state.workItem?.cancel()
state.workItem = nil
didSet(self.load(initialValue: initialValue))
}
didSet(self.load(initialValue: initialValue))
}
}
let deleteCancellable = self.storage.fileSystemSource(self.url, [.delete, .rename]) {
self.state.withValue { state in
state.workItem?.cancel()
state.workItem = nil
}
`didSet`(self.load(initialValue: initialValue))
setUpSources()
}
Expand Down Expand Up @@ -143,17 +157,19 @@ public final class FileStorageKey<Value: Codable & Sendable>: PersistenceKey, Se
}

private func performImmediately() {
guard let workItem = self.workItem.value
else { return }
self.storage.async(workItem)
self.storage.async(
DispatchWorkItem {
self.workItem.withValue {
$0?.cancel()
$0 = nil
self.state.withValue { state in
guard let workItem = state.workItem
else { return }
self.storage.async(workItem)
self.storage.async(
DispatchWorkItem {
self.state.withValue { state in
state.workItem?.cancel()
state.workItem = nil
}
}
}
)
)
}
}
}

Expand Down Expand Up @@ -270,7 +286,9 @@ public struct FileStorage: Hashable, Sendable {
asyncAfter: { scheduler.schedule(after: scheduler.now.advanced(by: .init($0)), $1.perform) },
createDirectory: { _, _ in },
fileExists: { fileSystem.keys.contains($0) },
fileSystemSource: { url, _, handler in
fileSystemSource: { url, event, handler in
guard event.contains(.write)
else { return AnyCancellable {} }
let handler = Handler(operation: handler)
sourceHandlers.withValue { _ = $0[url, default: []].insert(handler) }
return AnyCancellable {
Expand Down
50 changes: 49 additions & 1 deletion Tests/ComposableArchitectureTests/FileStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ final class FileStorageTests: XCTestCase {
}

@MainActor
func testWriteFileWhileDebouncing() throws {
func testWriteFileWhileThrottling() throws {
let fileSystem = LockIsolated<[URL: Data]>([:])
let scheduler = DispatchQueue.test
let fileStorage = FileStorage.inMemory(
Expand All @@ -235,6 +235,8 @@ final class FileStorageTests: XCTestCase {
@Shared(.fileStorage(.fileURL)) var users = [User]()

users.append(.blob)
try XCTAssertNoDifference(fileSystem.value.users(for: .fileURL), [.blob])

try fileStorage.save(Data(), .fileURL)
scheduler.run()
XCTAssertNoDifference(users, [])
Expand Down Expand Up @@ -386,6 +388,52 @@ final class FileStorageTests: XCTestCase {
XCTAssertEqual(shared1.wrappedValue.name, "Blob Jr")
XCTAssertEqual(shared2.wrappedValue.name, "Blob Sr")
}

func testCancelThrottleWhenFileIsDeleted() async throws {
try await withMainSerialExecutor {
try? FileManager.default.removeItem(at: .fileURL)

try await withDependencies {
$0.defaultFileStorage = .fileSystem
} operation: {
@Shared(.fileStorage(.fileURL)) var users = [User.blob]
await Task.yield()
XCTAssertNoDifference(users, [.blob])

$users.withLock { $0 = [.blobJr] } // NB: Saved immediately
$users.withLock { $0 = [.blobSr] } // NB: Throttled for 1 second
try FileManager.default.removeItem(at: .fileURL)
try await Task.sleep(nanoseconds: 1_200_000_000)
XCTAssertNoDifference(users, [.blob])
try XCTAssertEqual(Data(contentsOf: .fileURL), Data())
}
}
}

func testWritesFromManyThreads() async {
let fileSystem = LockIsolated<[URL: Data]>([:])
let fileStorage = FileStorage.inMemory(
fileSystem: fileSystem,
scheduler: DispatchQueue.main.eraseToAnyScheduler()
)

await withDependencies {
$0.defaultFileStorage = fileStorage
} operation: {
@Shared(.fileStorage(.fileURL)) var count = 0
let max = 10_000
await withTaskGroup(of: Void.self) { group in
for index in (1...max) {
group.addTask { [count = $count] in
try? await Task.sleep(for: .milliseconds(Int.random(in: 200...3_000)))
await count.withLock { $0 += index }
}
}
}

XCTAssertEqual(count, max * (max + 1) / 2)
}
}
}

extension URL {
Expand Down