Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 40 additions & 25 deletions lib/blocs/upload/upload_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ class UploadCubit extends Cubit<UploadState> {
bool _hasEmittedWarning = false;
bool _uploadIsInProgress = false;

/// Pre-computed file lengths cache. Populated in parallel at the start of
/// upload preparation to avoid redundant sequential file.ioFile.length reads.
Map<String, int> _fileLengthCache = {};
Comment on lines +107 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid key collisions in the file length cache.

getIdentifier() can collapse distinct files to the same key, especially for empty/blob paths where it returns only ioFile.name. That can overwrite one file’s cached length and make _getTotalSize() report the wrong total. Key the cache by the file object/IOFile identity instead of a derived string.

Proposed fix
-  Map<String, int> _fileLengthCache = {};
+  Map<IOFile, int> _fileLengthCache = {};
@@
-      size += _fileLengthCache[file.getIdentifier()] ??
+      size += _fileLengthCache[file.ioFile] ??
           await file.ioFile.length;
@@
-        _files[i].getIdentifier(): lengths[i],
+        _files[i].ioFile: lengths[i],

Also applies to: 557-558, 941-945

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/blocs/upload/upload_cubit.dart` around lines 105 - 107, The file length
cache in UploadCubit is using a derived string key from getIdentifier(), which
can collide for different files and overwrite cached lengths. Update
_fileLengthCache and its read/write sites in UploadCubit, especially around
_getTotalSize() and the upload preparation logic, to key by the file object or
IOFile identity instead of a string identifier. Make sure the cache lookup and
population use the same identity-based key so distinct files cannot share
entries.


/// Target folder
late Drive _targetDrive;
late FolderEntry _targetFolder;
Expand Down Expand Up @@ -566,7 +570,8 @@ class UploadCubit extends Cubit<UploadState> {
int size = 0;

for (final file in _files) {
size += await file.ioFile.length;
size += _fileLengthCache[file.getIdentifier()] ??
await file.ioFile.length;
}

return size;
Expand Down Expand Up @@ -738,22 +743,27 @@ class UploadCubit extends Cubit<UploadState> {

_removeFilesWithFolderNameConflicts();

for (final file in _files) {
final fileName = file.ioFile.name;
final existingFileIds = await _driveDao
.filesInFolderWithName(
driveId: _targetDrive.id,
parentFolderId: file.parentFolderId,
name: fileName,
)
.map((f) => f.id)
.get();

if (existingFileIds.isNotEmpty) {
final existingFileId = existingFileIds.first;
// Parallel conflict detection: check all files concurrently instead of
// one sequential DB query per file.
final conflictResults = await Future.wait(
_files.map((file) async {
final existingFileIds = await _driveDao
.filesInFolderWithName(
driveId: _targetDrive.id,
parentFolderId: file.parentFolderId,
name: file.ioFile.name,
)
.map((f) => f.id)
.get();
return (file: file, existingIds: existingFileIds);
}),
);

for (final result in conflictResults) {
if (result.existingIds.isNotEmpty) {
final existingFileId = result.existingIds.first;
logger.d('Found conflicting file. Existing file id: $existingFileId');
_conflictingFiles[file.getIdentifier()] = existingFileId;
_conflictingFiles[result.file.getIdentifier()] = existingFileId;
}
}

Expand Down Expand Up @@ -826,9 +836,9 @@ class UploadCubit extends Cubit<UploadState> {
Future<void> verifyFilesAboveWarningLimit() async {
emit(UploadPreparationInProgress());

/// This delay is necessary. Once we start the upload checks, we will perform high computational tasks.
/// This delay ensures the previous state (UploadPreparationInProgress) is updated before starting the upload checks.
await Future.delayed(const Duration(milliseconds: 100));
// Yield to allow the UI to render UploadPreparationInProgress before
// starting potentially heavy computation.
await Future.delayed(Duration.zero);

if (!_targetDrive.isPrivate) {
if (await _uploadFileSizeChecker.hasFileAboveWarningSizeLimit(
Expand Down Expand Up @@ -943,6 +953,16 @@ class UploadCubit extends Cubit<UploadState> {
_targetFolder =
await _driveDao.folderById(folderId: _parentFolderId).getSingle();

// Pre-compute all file lengths in parallel to avoid redundant sequential
// reads across warning check, conflict detection, and plan creation.
final lengths = await Future.wait(
_files.map((f) async => await f.ioFile.length),
);
_fileLengthCache = {
for (var i = 0; i < _files.length; i++)
_files[i].getIdentifier(): lengths[i],
};

// TODO: check if the backend refreshed the balance instead of a timer
if (isRetryingToPayWithTurbo) {
emit(UploadPreparationInProgress());
Expand Down Expand Up @@ -1046,11 +1066,6 @@ class UploadCubit extends Cubit<UploadState> {
);

try {
if (await _profileCubit.checkIfWalletMismatch()) {
emit(UploadWalletMismatch());
return;
}

final containsSupportedImageTypeForThumbnailGeneration = _files.any(
(element) => supportedImageTypesInFilePreview.contains(
element.ioFile.contentType,
Expand Down Expand Up @@ -1087,9 +1102,9 @@ class UploadCubit extends Cubit<UploadState> {
_uploadThumbnail = false;
}

if (manifestFileEntries.isNotEmpty) {
if (manifestFileEntries.isNotEmpty && _ants.isEmpty) {
try {
await _arnsRepository
_ants = await _arnsRepository
.getAntRecordsForWallet(_auth.currentUser.walletAddress);
} catch (e) {
logger.e(
Expand Down
84 changes: 46 additions & 38 deletions lib/core/upload/uploader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -287,19 +287,15 @@ class UploadPreparer {
}) : _uploadPlanUtils = uploadPlanUtils;

Future<UploadPlansPreparation> prepareFileUpload(UploadParams params) async {
final uploadPlanForAR = await _mountUploadPlan(
params: params,
method: UploadMethod.ar,
);

final uploadPlanForTurbo = await _mountUploadPlan(
params: params,
method: UploadMethod.turbo,
);
// Create both upload plans in parallel — they're independent.
final plans = await Future.wait([
_mountUploadPlan(params: params, method: UploadMethod.ar),
_mountUploadPlan(params: params, method: UploadMethod.turbo),
]);

return UploadPlansPreparation(
uploadPlanForAr: uploadPlanForAR,
uploadPlanForTurbo: uploadPlanForTurbo,
uploadPlanForAr: plans[0],
uploadPlanForTurbo: plans[1],
);
}

Expand Down Expand Up @@ -442,14 +438,17 @@ class UploadPaymentEvaluator {
// rejection.
final freeAllowanceFuture = getFreeAllowance();

/// Check the balance of the user
/// If we can't get the balance, turbo won't be available
turboBalance = await _getTurboBalance(canUseTurbo: _canUseTurbo);

final arBundleSizes = await sizeUtils
.getSizeOfAllBundles(uploadPlanForAR.bundleUploadHandles);
final arFileSizes = await sizeUtils
.getSizeOfAllV2Files(uploadPlanForAR.fileV2UploadHandles);
// Fetch turbo balance and compute AR sizes in parallel — these are
// independent operations (1 network call + 2 local computations). If the
// balance call fails turbo is marked unavailable inside _getTurboBalance.
final parallelResults = await Future.wait([
_getTurboBalance(canUseTurbo: _canUseTurbo),
sizeUtils.getSizeOfAllBundles(uploadPlanForAR.bundleUploadHandles),
sizeUtils.getSizeOfAllV2Files(uploadPlanForAR.fileV2UploadHandles),
]);
turboBalance = parallelResults[0] as TurboBalanceInterface;
final arBundleSizes = parallelResults[1] as int;
final arFileSizes = parallelResults[2] as int;

bool isUploadEligibleToTurbo =
uploadPlanForTurbo.fileV2UploadHandles.isEmpty &&
Expand All @@ -459,31 +458,40 @@ class UploadPaymentEvaluator {

int turboBundleSizes = 0;

/// Calculate the upload with Turbo if possible
if (isUploadEligibleToTurbo) {
turboBundleSizes = await sizeUtils
.getSizeOfAllBundles(uploadPlanForTurbo.bundleUploadHandles);

// Calculate AR and Turbo costs in parallel — they're independent.
final arCostFuture = () async {
try {
turboCostEstimate = await _turboUploadCostCalculator.calculateCost(
totalSize: turboBundleSizes,
return await _uploadCostEstimateCalculatorForAR.calculateCost(
totalSize: arBundleSizes + arFileSizes,
);
} catch (e) {
_isTurboAvailableToUploadAllFiles = false;
logger.e('Failed to get AR cost estimate, falling back to zero', e);
return UploadCostEstimate.zero();
}
}();

Future<UploadCostEstimate>? turboCostFuture;
if (isUploadEligibleToTurbo) {
turboBundleSizes = await sizeUtils
.getSizeOfAllBundles(uploadPlanForTurbo.bundleUploadHandles);
turboCostFuture = () async {
try {
return await _turboUploadCostCalculator.calculateCost(
totalSize: turboBundleSizes,
);
} catch (e) {
_isTurboAvailableToUploadAllFiles = false;
return UploadCostEstimate.zero();
}
}();
}

/// Calculate the upload cost with AR (D2N). If the gateway is
/// unavailable, fall back to a zero estimate so the modal can still
/// show Turbo as an option instead of crashing entirely.
UploadCostEstimate arCostEstimate;
try {
arCostEstimate = await _uploadCostEstimateCalculatorForAR.calculateCost(
totalSize: arBundleSizes + arFileSizes,
);
} catch (e) {
logger.e('Failed to get AR cost estimate, falling back to zero', e);
arCostEstimate = UploadCostEstimate.zero();
// Await both cost calculations (they've been running in parallel). The AR
// future already falls back to a zero estimate on gateway failure so the
// modal can still offer Turbo instead of crashing.
UploadCostEstimate arCostEstimate = await arCostFuture;
if (turboCostFuture != null) {
turboCostEstimate = await turboCostFuture;
}

final freeAllowance = await freeAllowanceFuture;
Expand Down
Loading