proxy: WatchFolder — size-stability partial-write guard, extension filter, cross-run skip list — 4 tests
This commit is contained in:
parent
92a29d5a60
commit
e1c1491cdd
2 changed files with 233 additions and 0 deletions
101
Sources/ForgeProxy/WatchFolder.swift
Normal file
101
Sources/ForgeProxy/WatchFolder.swift
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tracks files already handed off — shared across watcher restarts.
|
||||||
|
public actor ProcessedSkipList {
|
||||||
|
private var processed = Set<String>()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func contains(_ path: String) -> Bool {
|
||||||
|
processed.contains(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func mark(_ path: String) {
|
||||||
|
processed.insert(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polling watch folder. Emits file paths once they are:
|
||||||
|
/// - matching extension
|
||||||
|
/// - size-stable for `stabilityChecks` consecutive polls (partial-write guard)
|
||||||
|
/// - not in the skip list
|
||||||
|
/// Polling (not inotify): cheap at DIT-cart scale, identical behavior on
|
||||||
|
/// network volumes where inotify is unreliable.
|
||||||
|
public actor WatchFolder {
|
||||||
|
private let path: String
|
||||||
|
private let extensions: Set<String>
|
||||||
|
private let pollInterval: Duration
|
||||||
|
private let stabilityChecks: Int
|
||||||
|
private let skipList: ProcessedSkipList
|
||||||
|
|
||||||
|
private var sizeHistory: [String: (size: UInt64, stableCount: Int)] = [:]
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
private var continuation: AsyncStream<String>.Continuation?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
path: String,
|
||||||
|
extensions: [String],
|
||||||
|
pollInterval: Duration = .seconds(2),
|
||||||
|
stabilityChecks: Int = 2,
|
||||||
|
skipList: ProcessedSkipList = ProcessedSkipList()
|
||||||
|
) {
|
||||||
|
self.path = path
|
||||||
|
self.extensions = Set(extensions.map { $0.lowercased() })
|
||||||
|
self.pollInterval = pollInterval
|
||||||
|
self.stabilityChecks = stabilityChecks
|
||||||
|
self.skipList = skipList
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start watching; returns stream of ready file paths.
|
||||||
|
public func start() -> AsyncStream<String> {
|
||||||
|
let stream = AsyncStream<String> { continuation in
|
||||||
|
self.continuation = continuation
|
||||||
|
}
|
||||||
|
task = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await pollOnce()
|
||||||
|
try? await Task.sleep(for: pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
continuation?.finish()
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollOnce() async {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard let entries = try? fm.contentsOfDirectory(atPath: path) else { return }
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let ext = (entry as NSString).pathExtension.lowercased()
|
||||||
|
guard extensions.contains(ext) else { continue }
|
||||||
|
let full = path + "/" + entry
|
||||||
|
if await skipList.contains(full) { continue }
|
||||||
|
|
||||||
|
guard let attrs = try? fm.attributesOfItem(atPath: full),
|
||||||
|
let size = attrs[.size] as? UInt64 else { continue }
|
||||||
|
|
||||||
|
if let prev = sizeHistory[full] {
|
||||||
|
if prev.size == size {
|
||||||
|
let stable = prev.stableCount + 1
|
||||||
|
if stable >= stabilityChecks {
|
||||||
|
await skipList.mark(full)
|
||||||
|
sizeHistory.removeValue(forKey: full)
|
||||||
|
continuation?.yield(full)
|
||||||
|
} else {
|
||||||
|
sizeHistory[full] = (size, stable)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sizeHistory[full] = (size, 0) // still growing
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sizeHistory[full] = (size, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
Tests/ForgeProxyTests/WatchFolderTests.swift
Normal file
132
Tests/ForgeProxyTests/WatchFolderTests.swift
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
@testable import ForgeProxy
|
||||||
|
|
||||||
|
final class WatchFolderTests: XCTestCase {
|
||||||
|
|
||||||
|
var dir: String!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
dir = NSTemporaryDirectory() + "forge-watch-\(UUID().uuidString)"
|
||||||
|
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(atPath: dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New stable file enqueued exactly once.
|
||||||
|
func testNewFileEnqueuedOnce() async throws {
|
||||||
|
let watcher = WatchFolder(
|
||||||
|
path: dir,
|
||||||
|
extensions: ["mov"],
|
||||||
|
pollInterval: .milliseconds(30),
|
||||||
|
stabilityChecks: 2)
|
||||||
|
var enqueued = [String]()
|
||||||
|
let stream = await watcher.start()
|
||||||
|
|
||||||
|
try Data("clip".utf8).write(to: URL(fileURLWithPath: dir + "/C001.mov"))
|
||||||
|
|
||||||
|
let collector = Task {
|
||||||
|
for await path in stream {
|
||||||
|
enqueued.append(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(400))
|
||||||
|
await watcher.stop()
|
||||||
|
_ = await collector.result
|
||||||
|
|
||||||
|
XCTAssertEqual(enqueued.count, 1)
|
||||||
|
XCTAssertTrue(enqueued[0].hasSuffix("C001.mov"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Growing file (partial write) not enqueued until size stable.
|
||||||
|
func testGrowingFileWaitsForStability() async throws {
|
||||||
|
let watcher = WatchFolder(
|
||||||
|
path: dir,
|
||||||
|
extensions: ["mov"],
|
||||||
|
pollInterval: .milliseconds(40),
|
||||||
|
stabilityChecks: 2)
|
||||||
|
let path = dir + "/GROW.mov"
|
||||||
|
try Data("start".utf8).write(to: URL(fileURLWithPath: path))
|
||||||
|
|
||||||
|
let stream = await watcher.start()
|
||||||
|
actor Seen {
|
||||||
|
var paths = [String]()
|
||||||
|
var at: [ContinuousClock.Instant] = []
|
||||||
|
func add(_ p: String) {
|
||||||
|
paths.append(p)
|
||||||
|
at.append(.now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let seen = Seen()
|
||||||
|
let collector = Task {
|
||||||
|
for await p in stream {
|
||||||
|
await seen.add(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep growing for ~200ms.
|
||||||
|
for i in 0..<5 {
|
||||||
|
try await Task.sleep(for: .milliseconds(40))
|
||||||
|
let h = FileHandle(forWritingAtPath: path)!
|
||||||
|
h.seekToEndOfFile()
|
||||||
|
h.write(Data("more\(i)".utf8))
|
||||||
|
try h.close()
|
||||||
|
}
|
||||||
|
let growEnd = ContinuousClock.now
|
||||||
|
try await Task.sleep(for: .milliseconds(400))
|
||||||
|
await watcher.stop()
|
||||||
|
_ = await collector.result
|
||||||
|
|
||||||
|
let paths = await seen.paths
|
||||||
|
let times = await seen.at
|
||||||
|
XCTAssertEqual(paths.count, 1)
|
||||||
|
// Enqueue happened after growth stopped.
|
||||||
|
XCTAssertGreaterThan(times[0], growEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension filter.
|
||||||
|
func testExtensionFilter() async throws {
|
||||||
|
let watcher = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1)
|
||||||
|
try Data("x".utf8).write(to: URL(fileURLWithPath: dir + "/note.txt"))
|
||||||
|
try Data("y".utf8).write(to: URL(fileURLWithPath: dir + "/C002.mov"))
|
||||||
|
|
||||||
|
let stream = await watcher.start()
|
||||||
|
var enqueued = [String]()
|
||||||
|
let collector = Task {
|
||||||
|
for await p in stream { enqueued.append(p) }
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(300))
|
||||||
|
await watcher.stop()
|
||||||
|
_ = await collector.result
|
||||||
|
|
||||||
|
XCTAssertEqual(enqueued.count, 1)
|
||||||
|
XCTAssertTrue(enqueued[0].hasSuffix(".mov"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already-processed file skipped across watcher runs via skip list.
|
||||||
|
func testSkipListPersistsAcrossRuns() async throws {
|
||||||
|
try Data("x".utf8).write(to: URL(fileURLWithPath: dir + "/C003.mov"))
|
||||||
|
|
||||||
|
let skipList = ProcessedSkipList()
|
||||||
|
let w1 = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1, skipList: skipList)
|
||||||
|
let s1 = await w1.start()
|
||||||
|
var first = [String]()
|
||||||
|
let c1 = Task { for await p in s1 { first.append(p) } }
|
||||||
|
try await Task.sleep(for: .milliseconds(250))
|
||||||
|
await w1.stop()
|
||||||
|
_ = await c1.result
|
||||||
|
XCTAssertEqual(first.count, 1)
|
||||||
|
|
||||||
|
// Second watcher with same skip list: nothing new.
|
||||||
|
let w2 = WatchFolder(path: dir, extensions: ["mov"], pollInterval: .milliseconds(30), stabilityChecks: 1, skipList: skipList)
|
||||||
|
let s2 = await w2.start()
|
||||||
|
var second = [String]()
|
||||||
|
let c2 = Task { for await p in s2 { second.append(p) } }
|
||||||
|
try await Task.sleep(for: .milliseconds(250))
|
||||||
|
await w2.stop()
|
||||||
|
_ = await c2.result
|
||||||
|
XCTAssertTrue(second.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue