Fix changesets not getting closed, other updates to queue mechanism

This commit is contained in:
stopflock
2025-12-01 15:01:48 -06:00
parent 560a5db14d
commit dccafc898b
19 changed files with 1072 additions and 136 deletions
+192 -2
View File
@@ -1,10 +1,21 @@
import 'dart:math' as math;
import 'package:latlong2/latlong.dart';
import 'node_profile.dart';
import 'operator_profile.dart';
import '../state/settings_state.dart';
import '../dev_config.dart';
enum UploadOperation { create, modify, delete, extract }
enum UploadState {
pending, // Not started yet
creatingChangeset, // Creating changeset
uploading, // Node operation (create/modify/delete)
closingChangeset, // Closing changeset
error, // Upload failed (needs user retry) OR changeset not found
complete // Everything done
}
class PendingUpload {
final LatLng coord;
final dynamic direction; // Can be double or String for multiple directions
@@ -15,8 +26,16 @@ class PendingUpload {
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
int attempts;
bool error;
bool completing; // True when upload succeeded but item is showing checkmark briefly
bool error; // DEPRECATED: Use uploadState instead
String? errorMessage; // Detailed error message for debugging
bool completing; // DEPRECATED: Use uploadState instead
UploadState uploadState; // Current state in the upload pipeline
String? changesetId; // ID of changeset that needs closing
DateTime? nodeOperationCompletedAt; // When node operation completed (start of 59-minute countdown)
int changesetCloseAttempts; // Number of changeset close attempts
DateTime? lastChangesetCloseAttemptAt; // When we last tried to close changeset (for retry timing)
int nodeSubmissionAttempts; // Number of node submission attempts (separate from overall attempts)
DateTime? lastNodeSubmissionAttemptAt; // When we last tried to submit node (for retry timing)
PendingUpload({
required this.coord,
@@ -29,7 +48,15 @@ class PendingUpload {
this.submittedNodeId,
this.attempts = 0,
this.error = false,
this.errorMessage,
this.completing = false,
this.uploadState = UploadState.pending,
this.changesetId,
this.nodeOperationCompletedAt,
this.changesetCloseAttempts = 0,
this.lastChangesetCloseAttemptAt,
this.nodeSubmissionAttempts = 0,
this.lastNodeSubmissionAttemptAt,
}) : assert(
(operation == UploadOperation.create && originalNodeId == null) ||
(operation == UploadOperation.create) || (originalNodeId != null),
@@ -48,6 +75,53 @@ class PendingUpload {
// True if this is an extract operation (new node with tags from constrained node)
bool get isExtraction => operation == UploadOperation.extract;
// New state-based helpers
bool get needsUserRetry => uploadState == UploadState.error;
bool get isActivelyProcessing => uploadState == UploadState.creatingChangeset || uploadState == UploadState.uploading || uploadState == UploadState.closingChangeset;
bool get isComplete => uploadState == UploadState.complete;
bool get isPending => uploadState == UploadState.pending;
bool get isCreatingChangeset => uploadState == UploadState.creatingChangeset;
bool get isUploading => uploadState == UploadState.uploading;
bool get isClosingChangeset => uploadState == UploadState.closingChangeset;
// Calculate time until OSM auto-closes changeset (for UI display)
// This uses nodeOperationCompletedAt (when changeset was created) as the reference
Duration? get timeUntilAutoClose {
if (nodeOperationCompletedAt == null) return null;
final elapsed = DateTime.now().difference(nodeOperationCompletedAt!);
final remaining = kChangesetAutoCloseTimeout - elapsed;
return remaining.isNegative ? Duration.zero : remaining;
}
// Check if the 59-minute window has expired (for phases 2 & 3)
// This uses nodeOperationCompletedAt (when changeset was created) as the reference
bool get hasChangesetExpired {
if (nodeOperationCompletedAt == null) return false;
return DateTime.now().difference(nodeOperationCompletedAt!) >= kChangesetAutoCloseTimeout;
}
// Legacy method name for backward compatibility
bool get shouldGiveUpOnChangeset => hasChangesetExpired;
// Calculate next retry delay for changeset close using exponential backoff
Duration get nextChangesetCloseRetryDelay {
final delay = Duration(
milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds *
math.pow(kChangesetCloseBackoffMultiplier, changesetCloseAttempts)).round()
);
return delay > kChangesetCloseMaxRetryDelay
? kChangesetCloseMaxRetryDelay
: delay;
}
// Check if it's time to retry changeset close
bool get isReadyForChangesetCloseRetry {
if (lastChangesetCloseAttemptAt == null) return true; // First attempt
final nextRetryTime = lastChangesetCloseAttemptAt!.add(nextChangesetCloseRetryDelay);
return DateTime.now().isAfter(nextRetryTime);
}
// Get display name for the upload destination
String get uploadModeDisplayName {
@@ -61,6 +135,88 @@ class PendingUpload {
}
}
// Set error state with detailed message
void setError(String message) {
error = true; // Keep for backward compatibility
uploadState = UploadState.error;
errorMessage = message;
}
// Clear error state
void clearError() {
error = false; // Keep for backward compatibility
uploadState = UploadState.pending;
errorMessage = null;
attempts = 0;
changesetCloseAttempts = 0;
changesetId = null;
nodeOperationCompletedAt = null;
lastChangesetCloseAttemptAt = null;
nodeSubmissionAttempts = 0;
lastNodeSubmissionAttemptAt = null;
}
// Mark as creating changeset
void markAsCreatingChangeset() {
uploadState = UploadState.creatingChangeset;
error = false;
completing = false;
errorMessage = null;
}
// Mark changeset created, start node operation
void markChangesetCreated(String csId) {
uploadState = UploadState.uploading;
changesetId = csId;
nodeOperationCompletedAt = DateTime.now(); // Track when changeset was created for 59-minute timeout
}
// Mark node operation as complete, start changeset close phase
void markNodeOperationComplete() {
uploadState = UploadState.closingChangeset;
changesetCloseAttempts = 0;
// Note: nodeSubmissionAttempts preserved for debugging/stats
}
// Mark entire upload as complete
void markAsComplete() {
uploadState = UploadState.complete;
completing = true; // Keep for UI compatibility
error = false;
errorMessage = null;
}
// Increment changeset close attempt counter and record attempt time
void incrementChangesetCloseAttempts() {
changesetCloseAttempts++;
lastChangesetCloseAttemptAt = DateTime.now();
}
// Increment node submission attempt counter and record attempt time
void incrementNodeSubmissionAttempts() {
nodeSubmissionAttempts++;
lastNodeSubmissionAttemptAt = DateTime.now();
}
// Calculate next retry delay for node submission using exponential backoff
Duration get nextNodeSubmissionRetryDelay {
final delay = Duration(
milliseconds: (kChangesetCloseInitialRetryDelay.inMilliseconds *
math.pow(kChangesetCloseBackoffMultiplier, nodeSubmissionAttempts)).round()
);
return delay > kChangesetCloseMaxRetryDelay
? kChangesetCloseMaxRetryDelay
: delay;
}
// Check if it's time to retry node submission
bool get isReadyForNodeSubmissionRetry {
if (lastNodeSubmissionAttemptAt == null) return true; // First attempt
final nextRetryTime = lastNodeSubmissionAttemptAt!.add(nextNodeSubmissionRetryDelay);
return DateTime.now().isAfter(nextRetryTime);
}
// Get combined tags from node profile and operator profile
Map<String, String> getCombinedTags() {
// Deletions don't need tags
@@ -101,7 +257,15 @@ class PendingUpload {
'submittedNodeId': submittedNodeId,
'attempts': attempts,
'error': error,
'errorMessage': errorMessage,
'completing': completing,
'uploadState': uploadState.index,
'changesetId': changesetId,
'nodeOperationCompletedAt': nodeOperationCompletedAt?.millisecondsSinceEpoch,
'changesetCloseAttempts': changesetCloseAttempts,
'lastChangesetCloseAttemptAt': lastChangesetCloseAttemptAt?.millisecondsSinceEpoch,
'nodeSubmissionAttempts': nodeSubmissionAttempts,
'lastNodeSubmissionAttemptAt': lastNodeSubmissionAttemptAt?.millisecondsSinceEpoch,
};
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
@@ -123,7 +287,33 @@ class PendingUpload {
submittedNodeId: j['submittedNodeId'],
attempts: j['attempts'] ?? 0,
error: j['error'] ?? false,
errorMessage: j['errorMessage'], // Can be null for legacy entries
completing: j['completing'] ?? false, // Default to false for legacy entries
uploadState: j['uploadState'] != null
? UploadState.values[j['uploadState']]
: _migrateFromLegacyFields(j), // Migrate from legacy error/completing fields
changesetId: j['changesetId'],
nodeOperationCompletedAt: j['nodeOperationCompletedAt'] != null
? DateTime.fromMillisecondsSinceEpoch(j['nodeOperationCompletedAt'])
: null,
changesetCloseAttempts: j['changesetCloseAttempts'] ?? 0,
lastChangesetCloseAttemptAt: j['lastChangesetCloseAttemptAt'] != null
? DateTime.fromMillisecondsSinceEpoch(j['lastChangesetCloseAttemptAt'])
: null,
nodeSubmissionAttempts: j['nodeSubmissionAttempts'] ?? 0,
lastNodeSubmissionAttemptAt: j['lastNodeSubmissionAttemptAt'] != null
? DateTime.fromMillisecondsSinceEpoch(j['lastNodeSubmissionAttemptAt'])
: null,
);
// Helper to migrate legacy queue items to new state system
static UploadState _migrateFromLegacyFields(Map<String, dynamic> j) {
final error = j['error'] ?? false;
final completing = j['completing'] ?? false;
if (completing) return UploadState.complete;
if (error) return UploadState.error;
return UploadState.pending;
}
}