feat(app): enqueue gallery batch concurrently via Future.wait
Parallelize the enqueueForUpload collect loop (preparer's internal bounded semaphore from the previous task keeps IO in check). Also two test hardenings from that task's code review: pin that failed and thrown prepare operations release their concurrency slot (cap=1, subsequent prepare still completes), and pin the maxConcurrentPrepares <1 -> 1 clamp (peak 1, no deadlock). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -149,16 +149,17 @@ Future<EnqueueResult> enqueueForUpload(
|
||||
.where((path) => !skipSet.contains(path))
|
||||
.toList();
|
||||
|
||||
var enqueued = 0;
|
||||
for (final path in effective) {
|
||||
final task = await preparer.prepare(
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePath: path,
|
||||
);
|
||||
if (task.state == UploadTaskState.queued) enqueued++;
|
||||
}
|
||||
final results = await Future.wait(
|
||||
effective.map((path) => preparer.prepare(
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePath: path,
|
||||
)),
|
||||
);
|
||||
final enqueued = results
|
||||
.where((task) => task.state == UploadTaskState.queued)
|
||||
.length;
|
||||
|
||||
return EnqueueResult(
|
||||
enqueued: enqueued,
|
||||
|
||||
@@ -275,6 +275,46 @@ void main() {
|
||||
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
|
||||
expect(peakOf(), 1, reason: '并发 1 时任意时刻在途 prepare 至多 1(串行)');
|
||||
});
|
||||
|
||||
test('maxConcurrentPrepares<1 钳位为 1(0 不死锁、串行等价)', () async {
|
||||
final (p, peakOf) = probePreparer(0);
|
||||
final srcs = [
|
||||
for (var i = 0; i < 3; i++)
|
||||
sourceFile('clamp-$i.jpg', _jpgBytes + [i]),
|
||||
];
|
||||
final tasks = await Future.wait([
|
||||
for (final src in srcs)
|
||||
p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path),
|
||||
]);
|
||||
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
|
||||
expect(peakOf(), 1, reason: '运行时 <1 按 1 处理:峰值 1(不死锁、不并行)');
|
||||
});
|
||||
|
||||
test('失败与抛错操作都释放并发名额(cap=1 后续 prepare 不被卡死)', () async {
|
||||
final p = preparer(maxConcurrentPrepares: 1);
|
||||
final src = sourceFile('after-error.jpg', _jpgBytes);
|
||||
|
||||
// 业务失败:源不存在 → preparationFailed(正常返回、不抛)。
|
||||
final failed = await p.prepare(
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
sourcePath: '${sourceDir.path}/missing.jpg',
|
||||
);
|
||||
expect(failed.state, UploadTaskState.preparationFailed);
|
||||
|
||||
// 真错误:retry 不存在的任务 → StateError 穿过 _runBounded——
|
||||
// 钉死 whenComplete 语义(若改成 then,此处名额泄漏)。
|
||||
await expectLater(
|
||||
p.retry(accountId: 'alice', taskId: 'no-such-task', sourcePath: src.path),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
|
||||
// 名额已释放:后续 prepare 正常完成(泄漏则卡死,超时快速失败)。
|
||||
final task = await p
|
||||
.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
expect(task.state, UploadTaskState.queued);
|
||||
});
|
||||
});
|
||||
|
||||
group('串行回归(maxConcurrentPrepares=1 等价旧全局串行队列)', () {
|
||||
|
||||
Reference in New Issue
Block a user