mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-12 02:37:11 +02:00
Concurrent submissions
This commit is contained in:
1 parent
8b44b3abf5
commit
607ecbafaf
5 files changed
+119
-58
No files matched your search
+13
-4
@@ -202,15 +202,24 @@ Deletions don't need position dragging or tag editing - they just need confirmat
|
|||||||
- Retries: Exponential backoff up to 59 minutes
|
- Retries: Exponential backoff up to 59 minutes
|
||||||
- Failures: OSM auto-closes after 60 minutes, so we eventually give up
|
- Failures: OSM auto-closes after 60 minutes, so we eventually give up
|
||||||
|
|
||||||
**Queue processing workflow:**
|
**Queue processing workflow (v2.3.0+ concurrent processing):**
|
||||||
1. User action (add/edit/delete) → `PendingUpload` created with `UploadState.pending`
|
1. User action (add/edit/delete) → `PendingUpload` created with `UploadState.pending`
|
||||||
2. Immediate visual feedback (cache updated with temp markers)
|
2. Immediate visual feedback (cache updated with temp markers)
|
||||||
3. Background uploader processes queue when online:
|
3. Background uploader starts new uploads every 5 seconds (configurable via `kUploadQueueProcessingInterval`):
|
||||||
|
- **Concurrency limit**: Maximum 5 uploads processing simultaneously (`kMaxConcurrentUploads`)
|
||||||
|
- **Individual lifecycles**: Each upload processes through all three stages independently
|
||||||
|
- **Timer role**: Only used to start new pending uploads, not control stage progression
|
||||||
|
4. Each upload processes through stages without waiting for other uploads:
|
||||||
- **Pending** → Create changeset → **CreatingChangeset** → **Uploading**
|
- **Pending** → Create changeset → **CreatingChangeset** → **Uploading**
|
||||||
- **Uploading** → Upload node → **ClosingChangeset**
|
- **Uploading** → Upload node → **ClosingChangeset**
|
||||||
- **ClosingChangeset** → Close changeset → **Complete**
|
- **ClosingChangeset** → Close changeset → **Complete**
|
||||||
4. Success → cache updated with real data, temp markers removed
|
5. Success → cache updated with real data, temp markers removed
|
||||||
5. Failures → appropriate retry logic based on which stage failed
|
6. Failures → appropriate retry logic based on which stage failed
|
||||||
|
|
||||||
|
**Performance improvement (v2.3.0):**
|
||||||
|
- **Before**: Sequential processing with 10-second delays between each stage of each upload
|
||||||
|
- **After**: Concurrent processing with uploads completing in 10-30 seconds regardless of queue size
|
||||||
|
- **User benefit**: 3-5x faster upload processing for users with good internet connections
|
||||||
|
|
||||||
**Why three explicit stages:**
|
**Why three explicit stages:**
|
||||||
The previous implementation conflated changeset creation + node operation as one step, making error handling unclear. The new approach:
|
The previous implementation conflated changeset creation + node operation as one step, making error handling unclear. The new approach:
|
||||||
|
|||||||
@@ -98,8 +98,6 @@ cp lib/keys.dart.example lib/keys.dart
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Needed Bugfixes
|
### Needed Bugfixes
|
||||||
- Upload queue processing is dook
|
|
||||||
|
|
||||||
- Ensure GPS/follow-me works after recent revamp (loses lock? have to move map for button state to update?)
|
- Ensure GPS/follow-me works after recent revamp (loses lock? have to move map for button state to update?)
|
||||||
- Clean cache when nodes have been deleted by others
|
- Clean cache when nodes have been deleted by others
|
||||||
- Are offline areas preferred for fast loading even when online? Check working.
|
- Are offline areas preferred for fast loading even when online? Check working.
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"2.3.0": {
|
||||||
|
"content": [
|
||||||
|
"• MAJOR: Concurrent upload processing - uploads now process simultaneously instead of waiting in line",
|
||||||
|
"• Upload queue processing is now 3-5x faster for users with good internet connections",
|
||||||
|
"• Reduced queue processing interval from 10 seconds to 5 seconds for faster startup of new uploads",
|
||||||
|
"• Multiple uploads can now progress through their stages (create changeset → upload node → close changeset) at the same time",
|
||||||
|
"• Configurable concurrency limit (max 5 simultaneous uploads) prevents overwhelming OSM servers",
|
||||||
|
"• All existing error handling and retry logic preserved - only the timing has been improved"
|
||||||
|
]
|
||||||
|
},
|
||||||
"2.2.1": {
|
"2.2.1": {
|
||||||
"content": [
|
"content": [
|
||||||
"• Fixed network status indicator timing out prematurely while split requests were still loading",
|
"• Fixed network status indicator timing out prematurely while split requests were still loading",
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ const String kClientName = 'DeFlock';
|
|||||||
|
|
||||||
// Upload and changeset configuration
|
// Upload and changeset configuration
|
||||||
const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads
|
const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads
|
||||||
|
const Duration kUploadQueueProcessingInterval = Duration(seconds: 5); // How often to check for new uploads to start
|
||||||
|
const int kMaxConcurrentUploads = 5; // Maximum number of uploads processing simultaneously
|
||||||
const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10);
|
const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10);
|
||||||
const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes
|
const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes
|
||||||
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
||||||
|
|||||||
@@ -10,16 +10,19 @@ import '../models/node_profile.dart';
|
|||||||
import '../services/node_cache.dart';
|
import '../services/node_cache.dart';
|
||||||
import '../services/uploader.dart';
|
import '../services/uploader.dart';
|
||||||
import '../widgets/node_provider_with_cache.dart';
|
import '../widgets/node_provider_with_cache.dart';
|
||||||
|
import '../dev_config.dart';
|
||||||
import 'settings_state.dart';
|
import 'settings_state.dart';
|
||||||
import 'session_state.dart';
|
import 'session_state.dart';
|
||||||
|
|
||||||
class UploadQueueState extends ChangeNotifier {
|
class UploadQueueState extends ChangeNotifier {
|
||||||
final List<PendingUpload> _queue = [];
|
final List<PendingUpload> _queue = [];
|
||||||
Timer? _uploadTimer;
|
Timer? _uploadTimer;
|
||||||
|
int _activeUploadCount = 0;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
int get pendingCount => _queue.length;
|
int get pendingCount => _queue.length;
|
||||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||||
|
int get activeUploadCount => _activeUploadCount;
|
||||||
|
|
||||||
// Initialize by loading queue from storage and repopulate cache with pending nodes
|
// Initialize by loading queue from storage and repopulate cache with pending nodes
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
@@ -321,19 +324,22 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
|
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
|
||||||
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
|
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
|
||||||
|
|
||||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
_uploadTimer = Timer.periodic(kUploadQueueProcessingInterval, (t) async {
|
||||||
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
|
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
|
||||||
_uploadTimer?.cancel();
|
_uploadTimer?.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find next item to process based on state
|
// Check if we can start more uploads (concurrency limit check)
|
||||||
final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList();
|
if (_activeUploadCount >= kMaxConcurrentUploads) {
|
||||||
final creatingChangesetItems = _queue.where((pu) => pu.uploadState == UploadState.creatingChangeset).toList();
|
debugPrint('[UploadQueue] At concurrency limit ($_activeUploadCount/$kMaxConcurrentUploads), waiting for uploads to complete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any expired items
|
||||||
final uploadingItems = _queue.where((pu) => pu.uploadState == UploadState.uploading).toList();
|
final uploadingItems = _queue.where((pu) => pu.uploadState == UploadState.uploading).toList();
|
||||||
final closingItems = _queue.where((pu) => pu.uploadState == UploadState.closingChangeset).toList();
|
final closingItems = _queue.where((pu) => pu.uploadState == UploadState.closingChangeset).toList();
|
||||||
|
|
||||||
// Process any expired items
|
|
||||||
for (final uploadingItem in uploadingItems) {
|
for (final uploadingItem in uploadingItems) {
|
||||||
if (uploadingItem.hasChangesetExpired) {
|
if (uploadingItem.hasChangesetExpired) {
|
||||||
debugPrint('[UploadQueue] Changeset expired during node submission - marking as failed');
|
debugPrint('[UploadQueue] Changeset expired during node submission - marking as failed');
|
||||||
@@ -347,73 +353,109 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
if (closingItem.hasChangesetExpired) {
|
if (closingItem.hasChangesetExpired) {
|
||||||
debugPrint('[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)');
|
debugPrint('[UploadQueue] Changeset expired during close - trusting OSM auto-close (node was submitted successfully)');
|
||||||
_markAsCompleting(closingItem, submittedNodeId: closingItem.submittedNodeId!);
|
_markAsCompleting(closingItem, submittedNodeId: closingItem.submittedNodeId!);
|
||||||
// Continue processing loop - don't return here
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find next pending item to start
|
||||||
|
final pendingItems = _queue.where((pu) => pu.uploadState == UploadState.pending).toList();
|
||||||
|
|
||||||
// Find next item to process (process in stage order)
|
if (pendingItems.isEmpty) {
|
||||||
PendingUpload? item;
|
// Check if queue is effectively empty
|
||||||
if (pendingItems.isNotEmpty) {
|
|
||||||
item = pendingItems.first;
|
|
||||||
} else if (creatingChangesetItems.isNotEmpty) {
|
|
||||||
// Already in progress, skip
|
|
||||||
return;
|
|
||||||
} else if (uploadingItems.isNotEmpty) {
|
|
||||||
// Check if any uploading items are ready for retry
|
|
||||||
final readyToRetry = uploadingItems.where((ui) =>
|
|
||||||
!ui.hasChangesetExpired && ui.isReadyForNodeSubmissionRetry
|
|
||||||
).toList();
|
|
||||||
|
|
||||||
if (readyToRetry.isNotEmpty) {
|
|
||||||
item = readyToRetry.first;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No active items, check if any changeset close items are ready for retry
|
|
||||||
final readyToRetry = closingItems.where((ci) =>
|
|
||||||
!ci.hasChangesetExpired && ci.isReadyForChangesetCloseRetry
|
|
||||||
).toList();
|
|
||||||
|
|
||||||
if (readyToRetry.isNotEmpty) {
|
|
||||||
item = readyToRetry.first;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item == null) {
|
|
||||||
// No items ready for processing - check if queue is effectively empty
|
|
||||||
final hasActiveItems = _queue.any((pu) =>
|
final hasActiveItems = _queue.any((pu) =>
|
||||||
pu.uploadState == UploadState.pending ||
|
|
||||||
pu.uploadState == UploadState.creatingChangeset ||
|
pu.uploadState == UploadState.creatingChangeset ||
|
||||||
(pu.uploadState == UploadState.uploading && !pu.hasChangesetExpired) ||
|
pu.uploadState == UploadState.uploading ||
|
||||||
(pu.uploadState == UploadState.closingChangeset && !pu.hasChangesetExpired)
|
pu.uploadState == UploadState.closingChangeset
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasActiveItems) {
|
if (!hasActiveItems) {
|
||||||
debugPrint('[UploadQueue] No active items remaining, stopping uploader');
|
debugPrint('[UploadQueue] No active items remaining, stopping uploader');
|
||||||
_uploadTimer?.cancel();
|
_uploadTimer?.cancel();
|
||||||
}
|
}
|
||||||
return; // Nothing to process right now
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve access after every tick (accounts for re-login)
|
// Retrieve access token
|
||||||
final access = await getAccessToken();
|
final access = await getAccessToken();
|
||||||
if (access == null) return; // not logged in
|
if (access == null) return; // not logged in
|
||||||
|
|
||||||
debugPrint('[UploadQueue] Processing item in state: ${item.uploadState} with uploadMode: ${item.uploadMode}');
|
// Start processing the next pending upload
|
||||||
|
final item = pendingItems.first;
|
||||||
|
debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)');
|
||||||
|
|
||||||
if (item.uploadState == UploadState.pending) {
|
_activeUploadCount++;
|
||||||
await _processCreateChangeset(item, access);
|
_processIndividualUpload(item, access);
|
||||||
} else if (item.uploadState == UploadState.creatingChangeset) {
|
|
||||||
// Already in progress, skip (shouldn't happen due to filtering above)
|
|
||||||
debugPrint('[UploadQueue] Changeset creation already in progress, skipping');
|
|
||||||
return;
|
|
||||||
} else if (item.uploadState == UploadState.uploading) {
|
|
||||||
await _processNodeOperation(item, access);
|
|
||||||
} else if (item.uploadState == UploadState.closingChangeset) {
|
|
||||||
await _processChangesetClose(item, access);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process an individual upload through all three stages
|
||||||
|
Future<void> _processIndividualUpload(PendingUpload item, String accessToken) async {
|
||||||
|
try {
|
||||||
|
debugPrint('[UploadQueue] Starting individual upload processing for ${item.operation.name} at ${item.coord}');
|
||||||
|
|
||||||
|
// Stage 1: Create changeset
|
||||||
|
await _processCreateChangeset(item, accessToken);
|
||||||
|
if (item.uploadState == UploadState.error) return;
|
||||||
|
|
||||||
|
// Stage 2: Node operation with retry logic
|
||||||
|
bool nodeOperationCompleted = false;
|
||||||
|
while (!nodeOperationCompleted && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
|
||||||
|
await _processNodeOperation(item, accessToken);
|
||||||
|
|
||||||
|
if (item.uploadState == UploadState.closingChangeset) {
|
||||||
|
// Node operation succeeded
|
||||||
|
nodeOperationCompleted = true;
|
||||||
|
} else if (item.uploadState == UploadState.uploading && !item.isReadyForNodeSubmissionRetry) {
|
||||||
|
// Need to wait before retry
|
||||||
|
final delay = item.nextNodeSubmissionRetryDelay;
|
||||||
|
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before node submission retry');
|
||||||
|
await Future.delayed(delay);
|
||||||
|
} else if (item.uploadState == UploadState.error) {
|
||||||
|
// Failed permanently
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodeOperationCompleted) return; // Failed or expired
|
||||||
|
|
||||||
|
// Stage 3: Close changeset with retry logic
|
||||||
|
bool changesetClosed = false;
|
||||||
|
while (!changesetClosed && !item.hasChangesetExpired && item.uploadState != UploadState.error) {
|
||||||
|
await _processChangesetClose(item, accessToken);
|
||||||
|
|
||||||
|
if (item.uploadState == UploadState.complete) {
|
||||||
|
// Changeset close succeeded
|
||||||
|
changesetClosed = true;
|
||||||
|
} else if (item.uploadState == UploadState.closingChangeset && !item.isReadyForChangesetCloseRetry) {
|
||||||
|
// Need to wait before retry
|
||||||
|
final delay = item.nextChangesetCloseRetryDelay;
|
||||||
|
debugPrint('[UploadQueue] Waiting ${delay.inSeconds}s before changeset close retry');
|
||||||
|
await Future.delayed(delay);
|
||||||
|
} else if (item.uploadState == UploadState.error) {
|
||||||
|
// Failed permanently
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changesetClosed && item.hasChangesetExpired) {
|
||||||
|
// Trust OSM auto-close if we ran out of time
|
||||||
|
debugPrint('[UploadQueue] Upload completed but changeset close timed out - trusting OSM auto-close');
|
||||||
|
if (item.submittedNodeId != null) {
|
||||||
|
_markAsCompleting(item, submittedNodeId: item.submittedNodeId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[UploadQueue] Unexpected error in individual upload processing: $e');
|
||||||
|
item.setError('Unexpected error: $e');
|
||||||
|
_saveQueue();
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
// Always decrement the active upload count
|
||||||
|
_activeUploadCount--;
|
||||||
|
debugPrint('[UploadQueue] Individual upload processing finished ($_activeUploadCount/$kMaxConcurrentUploads active)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process changeset creation (step 1 of 3)
|
// Process changeset creation (step 1 of 3)
|
||||||
Future<void> _processCreateChangeset(PendingUpload item, String access) async {
|
Future<void> _processCreateChangeset(PendingUpload item, String access) async {
|
||||||
item.markAsCreatingChangeset();
|
item.markAsCreatingChangeset();
|
||||||
|
|||||||
Reference in new issue
Block a user