feat(app): bounded-parallel prepare (default 3) replaces serial queue

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 13:43:07 +08:00
parent dc5ae4a24a
commit b79edf447b
2 changed files with 114 additions and 16 deletions
@@ -47,11 +47,15 @@ void main() {
return f;
}
UploadPreparer preparer({int maxBytes = UploadPreparer.kMaxUploadBytes}) =>
UploadPreparer preparer({
int maxBytes = UploadPreparer.kMaxUploadBytes,
int maxConcurrentPrepares = kMaxConcurrentPrepares,
}) =>
UploadPreparer(
store,
baseDirProvider: () async => managedBase,
maxBytes: maxBytes,
maxConcurrentPrepares: maxConcurrentPrepares,
);
group('prepare(快乐路径)', () {
@@ -216,9 +220,66 @@ void main() {
});
});
group('串行执行(连拍防 IO 风暴)', () {
test('并发 prepare 按提交顺序串行完成,各自独立成行', () async {
final p = preparer();
group('有界并发执行(连拍防 IO 风暴的上限钳位)', () {
/// 构造带并发探针的准备器:baseDirProvider 在 _prepare 内被 await,
/// 其重叠度是「在途 prepare 数」的可观测下界(且受信号量上限约束)。
(UploadPreparer, int Function()) probePreparer(int limit) {
var active = 0;
var peak = 0;
final p = UploadPreparer(
store,
baseDirProvider: () async {
active++;
if (active > peak) peak = active;
await Future<void>.delayed(const Duration(milliseconds: 30));
active--;
return managedBase;
},
maxConcurrentPrepares: limit,
);
return (p, () => peak);
}
test('并发 prepare 峰值 = maxConcurrentPrepares(≤上限且真并行),9 任务全部 queued', () async {
final (p, peakOf) = probePreparer(3);
final srcs = [
for (var i = 0; i < 9; i++)
sourceFile('par-$i.jpg', _jpgBytes + [i]),
];
final tasks = await Future.wait([
for (final src in srcs)
p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path),
]);
// 全部入队、ID/幂等键互不相同、副本都在。
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
expect(tasks.map((t) => t.id).toSet().length, 9);
expect(tasks.map((t) => t.clientTaskId).toSet().length, 9);
for (final t in tasks) {
expect(File(t.sourceFilePath).existsSync(), isTrue);
}
// 峰值恰为 3:≤3 证明上限钳位(防连拍 IO 风暴初衷保留);
// 达到 3 证明未退化为串行。
expect(peakOf(), 3);
});
test('maxConcurrentPrepares=1 等价旧串行行为:峰值 1、任务仍全部成功', () async {
final (p, peakOf) = probePreparer(1);
final srcs = [
for (var i = 0; i < 4; i++)
sourceFile('ser-$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 时任意时刻在途 prepare 至多 1(串行)');
});
});
group('串行回归(maxConcurrentPrepares=1 等价旧全局串行队列)', () {
test('并发=1:prepare 按提交顺序串行完成,各自独立成行', () async {
final p = preparer(maxConcurrentPrepares: 1);
final srcs = [
for (var i = 0; i < 3; i++)
sourceFile('burst-$i.jpg', _jpgBytes + [i]),
@@ -234,7 +295,7 @@ void main() {
for (final t in tasks) {
expect(File(t.sourceFilePath).existsSync(), isTrue);
}
// createdAt 严格递增(串行执行的间接证据:无交叉写库)。
// createdAt 非降(并发=1 串行的间接证据:无交叉写库)。
final stamps = tasks.map((t) => t.createdAt).toList();
for (var i = 1; i < stamps.length; i++) {
expect(!stamps[i].isBefore(stamps[i - 1]), isTrue);