blob: 6224a4e7a86af60a449ae88bfe5f6ebab2cd7138 [file] [edit]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math';
import 'package:file/file.dart';
/// Performs a transactional update of a file in Google Cloud Storage.
///
/// It downloads the file, calls the [callback] to get the new contents, and
/// uploads the new contents. It uses generation IDs to ensure that the file
/// has not been modified between download and upload.
///
/// If the file is modified concurrently, it retries the operation up to
/// [maxRetries] times.
Future<void> transactionalUpdate({
required String gsPath,
required Future<String> Function(String currentContents) callback,
required Future<String> Function(List<String> args) runGsUtil,
required FileSystem fs,
int maxRetries = 5,
Directory? tempDirectory,
bool dryRun = false,
}) async {
if (dryRun) {
print('Dry run: simulating update for $gsPath');
final String newContents = await callback('');
print('Resulting manifest content:\n$newContents');
return;
}
final createTemp = tempDirectory == null;
final Directory tempDir =
tempDirectory ?? fs.systemTempDirectory.createTempSync('transactional_update.');
try {
await _transactionalUpdate(
gsPath: gsPath,
callback: callback,
runGsUtil: runGsUtil,
fs: fs,
maxRetries: maxRetries,
tempDir: tempDir,
);
} finally {
if (createTemp) {
try {
tempDir.deleteSync(recursive: true);
} catch (e) {
print('Failed to delete temp directory ${tempDir.path}: $e');
}
}
}
}
Future<void> _transactionalUpdate({
required String gsPath,
required Future<String> Function(String currentContents) callback,
required Future<String> Function(List<String> args) runGsUtil,
required FileSystem fs,
required int maxRetries,
required Directory tempDir,
}) async {
final generationRegex = RegExp(r'Generation:\s+(\d+)');
for (var attempt = 1; attempt <= maxRetries; attempt++) {
print('Attempt $attempt of $maxRetries to update $gsPath');
var generation = '0';
var fileExists = true;
try {
final String statOutput = await runGsUtil(<String>['stat', gsPath]);
final Match? match = generationRegex.firstMatch(statOutput);
if (match == null) {
throw Exception('Could not find generation ID in stat output:\n$statOutput');
}
generation = match.group(1)!;
} catch (e) {
print('Failed to stat $gsPath, assuming file does not exist: $e');
fileExists = false;
generation = '0';
}
final File localFile = fs.file(fs.path.join(tempDir.path, 'downloaded.json'));
var contents = '';
if (fileExists) {
try {
await runGsUtil(<String>['cp', '$gsPath#$generation', localFile.path]);
contents = localFile.readAsStringSync();
} catch (e) {
print('Failed to download generation $generation of $gsPath: $e');
if (attempt == maxRetries) {
rethrow;
}
final int backoffMs = pow(2, attempt).toInt() * 1000;
final int jitterMs = Random().nextInt(1000);
await Future<void>.delayed(Duration(milliseconds: backoffMs + jitterMs));
continue;
}
}
final String newContents = await callback(contents);
final File uploadFile = fs.file(fs.path.join(tempDir.path, 'upload.json'));
uploadFile.writeAsStringSync(newContents);
try {
await runGsUtil(<String>[
'-h',
'x-goog-if-generation-match:$generation',
'cp',
uploadFile.path,
gsPath,
]);
print('Successfully updated $gsPath');
return;
} catch (e) {
print('Failed to upload $gsPath with generation match $generation: $e');
if (attempt == maxRetries) {
rethrow;
}
final int backoffMs = pow(2, attempt).toInt() * 1000;
final int jitterMs = Random().nextInt(1000);
await Future<void>.delayed(Duration(milliseconds: backoffMs + jitterMs));
continue;
}
}
throw Exception('Failed to update $gsPath after $maxRetries attempts');
}