| // 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'); | |
| } |