Skip to content

Commit a93bc55

Browse files
authored
v2 async sync-local-files: fix 504 timeouts on large audio uploads (#6157)
2 parents 177bb36 + 5a69dff commit a93bc55

81 files changed

Lines changed: 2669 additions & 30 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/lib/backend/http/api/conversations.dart

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,10 @@ Future<List<ServerConversation>> sendStorageToBackend(File file, String sdCardDa
346346
Future<SyncLocalFilesResponse> syncLocalFiles(List<File> files, {UploadProgressCallback? onUploadProgress}) async {
347347
try {
348348
var response = await makeMultipartApiCall(
349-
url: '${Env.apiBaseUrl}v1/sync-local-files', files: files, onUploadProgress: onUploadProgress);
349+
url: '${Env.apiBaseUrl}v1/sync-local-files',
350+
files: files,
351+
onUploadProgress: onUploadProgress,
352+
);
350353

351354
if (response.statusCode == 200 || response.statusCode == 207) {
352355
var result = SyncLocalFilesResponse.fromJson(jsonDecode(response.body));
@@ -374,6 +377,106 @@ Future<SyncLocalFilesResponse> syncLocalFiles(List<File> files, {UploadProgressC
374377
}
375378
}
376379

380+
/// v2 async sync: POST files → 202 with job_id, then poll until terminal.
381+
/// Returns the same SyncLocalFilesResponse as v1 once processing is confirmed complete.
382+
typedef SyncJobPollCallback = void Function(SyncJobStatusResponse status);
383+
384+
Future<SyncLocalFilesResponse> syncLocalFilesV2(
385+
List<File> files, {
386+
UploadProgressCallback? onUploadProgress,
387+
SyncJobPollCallback? onPollProgress,
388+
}) async {
389+
try {
390+
// Step 1: Submit files
391+
var response = await makeMultipartApiCall(
392+
url: '${Env.apiBaseUrl}v2/sync-local-files',
393+
files: files,
394+
onUploadProgress: onUploadProgress,
395+
);
396+
397+
// Fast-path responses (no async job created)
398+
if (response.statusCode == 200) {
399+
return SyncLocalFilesResponse.fromJson(jsonDecode(response.body));
400+
}
401+
402+
if (response.statusCode != 202) {
403+
if (response.statusCode == 400) {
404+
throw Exception('Audio file could not be processed by server');
405+
} else if (response.statusCode == 413) {
406+
throw Exception('Audio file is too large to upload');
407+
} else if (response.statusCode == 429) {
408+
throw Exception('Rate limited or budget exhausted');
409+
} else if (response.statusCode >= 500) {
410+
throw Exception('Server is temporarily unavailable');
411+
} else {
412+
throw Exception('Upload failed unexpectedly');
413+
}
414+
}
415+
416+
// Step 2: Poll for completion
417+
var startResponse = SyncJobStartResponse.fromJson(jsonDecode(response.body));
418+
var jobId = startResponse.jobId;
419+
var pollInterval = Duration(milliseconds: startResponse.pollAfterMs);
420+
421+
const maxPolls = 120; // 120 x 3s = 6 minutes max
422+
for (var i = 0; i < maxPolls; i++) {
423+
await Future.delayed(pollInterval);
424+
425+
var pollResponse = await makeApiCall(
426+
url: '${Env.apiBaseUrl}v2/sync-local-files/$jobId',
427+
headers: {},
428+
method: 'GET',
429+
body: '',
430+
);
431+
432+
if (pollResponse == null) {
433+
Logger.debug('syncLocalFilesV2 poll failed: null response');
434+
continue; // Retry on transient errors
435+
}
436+
437+
// Terminal errors — don't retry
438+
if (pollResponse.statusCode == 404) {
439+
throw Exception('Sync job not found or expired');
440+
}
441+
if (pollResponse.statusCode == 403) {
442+
throw Exception('Not authorized to view this sync job');
443+
}
444+
if (pollResponse.statusCode != 200) {
445+
Logger.debug('syncLocalFilesV2 poll failed: ${pollResponse.statusCode}');
446+
continue; // Retry on transient errors
447+
}
448+
449+
var jobStatus = SyncJobStatusResponse.fromJson(jsonDecode(pollResponse.body));
450+
451+
// Report poll progress to caller for UI updates
452+
onPollProgress?.call(jobStatus);
453+
454+
if (jobStatus.isTerminal) {
455+
// All segments failed → throw to match v1's 500 behavior (WAL stays retryable)
456+
if (jobStatus.status == 'failed') {
457+
throw Exception(jobStatus.error ?? 'Sync job failed');
458+
}
459+
// Success or partial failure → return result
460+
if (jobStatus.result != null) {
461+
return jobStatus.result!;
462+
}
463+
return SyncLocalFilesResponse(
464+
newConversationIds: [],
465+
updatedConversationIds: [],
466+
failedSegments: jobStatus.failedSegments,
467+
totalSegments: jobStatus.totalSegments,
468+
);
469+
}
470+
}
471+
472+
// Polling timed out — don't mark as synced
473+
throw Exception('Sync job timed out waiting for results');
474+
} catch (e) {
475+
Logger.debug('syncLocalFilesV2 error: $e');
476+
rethrow;
477+
}
478+
}
479+
377480
Future<(List<ServerConversation>, int, int)> searchConversationsServer(
378481
String query, {
379482
int? page,

app/lib/backend/schema/conversation.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,75 @@ class SyncLocalFilesResponse {
423423
}
424424
}
425425

426+
class SyncJobStartResponse {
427+
final String jobId;
428+
final String status;
429+
final int totalFiles;
430+
final int totalSegments;
431+
final int pollAfterMs;
432+
433+
SyncJobStartResponse({
434+
required this.jobId,
435+
required this.status,
436+
required this.totalFiles,
437+
required this.totalSegments,
438+
required this.pollAfterMs,
439+
});
440+
441+
factory SyncJobStartResponse.fromJson(Map<String, dynamic> json) {
442+
return SyncJobStartResponse(
443+
jobId: json['job_id'] ?? '',
444+
status: json['status'] ?? 'queued',
445+
totalFiles: json['total_files'] ?? 0,
446+
totalSegments: json['total_segments'] ?? 0,
447+
pollAfterMs: json['poll_after_ms'] ?? 3000,
448+
);
449+
}
450+
}
451+
452+
class SyncJobStatusResponse {
453+
final String jobId;
454+
final String status;
455+
final int totalSegments;
456+
final int processedSegments;
457+
final int successfulSegments;
458+
final int failedSegments;
459+
final SyncLocalFilesResponse? result;
460+
final String? error;
461+
462+
SyncJobStatusResponse({
463+
required this.jobId,
464+
required this.status,
465+
this.totalSegments = 0,
466+
this.processedSegments = 0,
467+
this.successfulSegments = 0,
468+
this.failedSegments = 0,
469+
this.result,
470+
this.error,
471+
});
472+
473+
bool get isTerminal => status == 'completed' || status == 'partial_failure' || status == 'failed';
474+
bool get isSuccess => status == 'completed';
475+
bool get isPartialFailure => status == 'partial_failure';
476+
477+
factory SyncJobStatusResponse.fromJson(Map<String, dynamic> json) {
478+
SyncLocalFilesResponse? result;
479+
if (json['result'] != null) {
480+
result = SyncLocalFilesResponse.fromJson(json['result']);
481+
}
482+
return SyncJobStatusResponse(
483+
jobId: json['job_id'] ?? '',
484+
status: json['status'] ?? 'unknown',
485+
totalSegments: json['total_segments'] ?? 0,
486+
processedSegments: json['processed_segments'] ?? 0,
487+
successfulSegments: json['successful_segments'] ?? 0,
488+
failedSegments: json['failed_segments'] ?? 0,
489+
result: result,
490+
error: json['error'],
491+
);
492+
}
493+
}
494+
426495
enum SyncedConversationType { newConversation, updatedConversation }
427496

428497
class SyncedConversationPointer {

app/lib/l10n/app_ar.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2602,6 +2602,18 @@
26022602
"downloadingFromDevice": "جارٍ التنزيل من الجهاز",
26032603
"reconnectingToInternet": "جارٍ إعادة الاتصال بالإنترنت...",
26042604
"uploadingToCloud": "جارٍ رفع {current} من {total}",
2605+
"processingOnServer": "جاري المعالجة على الخادم...",
2606+
"processingOnServerProgress": "جاري المعالجة... {current}/{total} أجزاء",
2607+
"@processingOnServerProgress": {
2608+
"placeholders": {
2609+
"current": {
2610+
"type": "int"
2611+
},
2612+
"total": {
2613+
"type": "int"
2614+
}
2615+
}
2616+
},
26052617
"processedStatus": "تمت المعالجة",
26062618
"corruptedStatus": "تالف",
26072619
"nPending": "{count} قيد الانتظار",

app/lib/l10n/app_bg.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2604,6 +2604,18 @@
26042604
"downloadingFromDevice": "Изтегляне от устройството",
26052605
"reconnectingToInternet": "Повторно свързване с интернет...",
26062606
"uploadingToCloud": "Качване на {current} от {total}",
2607+
"processingOnServer": "Обработка на сървъра...",
2608+
"processingOnServerProgress": "Обработка... {current}/{total} сегмента",
2609+
"@processingOnServerProgress": {
2610+
"placeholders": {
2611+
"current": {
2612+
"type": "int"
2613+
},
2614+
"total": {
2615+
"type": "int"
2616+
}
2617+
}
2618+
},
26072619
"processedStatus": "Обработено",
26082620
"corruptedStatus": "Повредено",
26092621
"nPending": "{count} в изчакване",

app/lib/l10n/app_ca.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2604,6 +2604,18 @@
26042604
"downloadingFromDevice": "Descarregant del dispositiu",
26052605
"reconnectingToInternet": "Reconnectant a internet...",
26062606
"uploadingToCloud": "Pujant {current} de {total}",
2607+
"processingOnServer": "Processant al servidor...",
2608+
"processingOnServerProgress": "Processant... {current}/{total} segments",
2609+
"@processingOnServerProgress": {
2610+
"placeholders": {
2611+
"current": {
2612+
"type": "int"
2613+
},
2614+
"total": {
2615+
"type": "int"
2616+
}
2617+
}
2618+
},
26072619
"processedStatus": "Processat",
26082620
"corruptedStatus": "Corrupte",
26092621
"nPending": "{count} pendents",

app/lib/l10n/app_cs.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2604,6 +2604,18 @@
26042604
"downloadingFromDevice": "Stahování ze zařízení",
26052605
"reconnectingToInternet": "Opětovné připojování k internetu...",
26062606
"uploadingToCloud": "Nahrávání {current} z {total}",
2607+
"processingOnServer": "Zpracování na serveru...",
2608+
"processingOnServerProgress": "Zpracování... {current}/{total} segmentů",
2609+
"@processingOnServerProgress": {
2610+
"placeholders": {
2611+
"current": {
2612+
"type": "int"
2613+
},
2614+
"total": {
2615+
"type": "int"
2616+
}
2617+
}
2618+
},
26072619
"processedStatus": "Zpracováno",
26082620
"corruptedStatus": "Poškozeno",
26092621
"nPending": "{count} čekajících",

app/lib/l10n/app_da.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2644,6 +2644,18 @@
26442644
"downloadingFromDevice": "Downloader fra enhed",
26452645
"reconnectingToInternet": "Genopretter forbindelse til internet...",
26462646
"uploadingToCloud": "Uploader {current} af {total}",
2647+
"processingOnServer": "Behandler på serveren...",
2648+
"processingOnServerProgress": "Behandler... {current}/{total} segmenter",
2649+
"@processingOnServerProgress": {
2650+
"placeholders": {
2651+
"current": {
2652+
"type": "int"
2653+
},
2654+
"total": {
2655+
"type": "int"
2656+
}
2657+
}
2658+
},
26472659
"processedStatus": "Behandlet",
26482660
"corruptedStatus": "Beskadiget",
26492661
"nPending": "{count} afventende",

app/lib/l10n/app_de.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2603,6 +2603,18 @@
26032603
"downloadingFromDevice": "Wird vom Gerät heruntergeladen",
26042604
"reconnectingToInternet": "Verbindung zum Internet wird wiederhergestellt...",
26052605
"uploadingToCloud": "Hochladen von {current} von {total}",
2606+
"processingOnServer": "Verarbeitung auf dem Server...",
2607+
"processingOnServerProgress": "Verarbeitung... {current}/{total} Segmente",
2608+
"@processingOnServerProgress": {
2609+
"placeholders": {
2610+
"current": {
2611+
"type": "int"
2612+
},
2613+
"total": {
2614+
"type": "int"
2615+
}
2616+
}
2617+
},
26062618
"processedStatus": "Verarbeitet",
26072619
"corruptedStatus": "Beschädigt",
26082620
"nPending": "{count} ausstehend",

app/lib/l10n/app_el.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2635,6 +2635,18 @@
26352635
"downloadingFromDevice": "Λήψη από τη συσκευή",
26362636
"reconnectingToInternet": "Επανασύνδεση στο διαδίκτυο...",
26372637
"uploadingToCloud": "Μεταφόρτωση {current} από {total}",
2638+
"processingOnServer": "Επεξεργασία στον διακομιστή...",
2639+
"processingOnServerProgress": "Επεξεργασία... {current}/{total} τμήματα",
2640+
"@processingOnServerProgress": {
2641+
"placeholders": {
2642+
"current": {
2643+
"type": "int"
2644+
},
2645+
"total": {
2646+
"type": "int"
2647+
}
2648+
}
2649+
},
26382650
"processedStatus": "Επεξεργασμένο",
26392651
"corruptedStatus": "Κατεστραμμένο",
26402652
"nPending": "{count} σε αναμονή",

app/lib/l10n/app_en.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9972,6 +9972,18 @@
99729972
}
99739973
}
99749974
},
9975+
"processingOnServer": "Processing on server...",
9976+
"processingOnServerProgress": "Processing... {current}/{total} segments",
9977+
"@processingOnServerProgress": {
9978+
"placeholders": {
9979+
"current": {
9980+
"type": "int"
9981+
},
9982+
"total": {
9983+
"type": "int"
9984+
}
9985+
}
9986+
},
99759987
"processedStatus": "Processed",
99769988
"corruptedStatus": "Corrupted",
99779989
"nPending": "{count} pending",

0 commit comments

Comments
 (0)