Skip to content

Commit d4752c4

Browse files
[metrics_center] Add retries to unlock a lock file in case of 504 errors (#4323)
* flutter/flutter#120440 * Updates the `GcsLock` class to retry unlocking the file in case a 504 error occurs * Updates the `GcsLock` constructor to require a `StorageApi` object instead of `AuthClient`, which allows mocking the object, since the `AuthClient` object isn't actually being used within the `GcsLock` class besides to create the `StorageApi`.
1 parent 5d6e48c commit d4752c4

File tree

9 files changed

+1396
-56
lines changed

9 files changed

+1396
-56
lines changed

packages/metrics_center/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.0.10
2+
3+
* Adds retry logic when removing a `GcsLock` file lock in case of failure.
4+
15
## 1.0.9
26

37
* Adds compatibility with `http` 1.0.

packages/metrics_center/lib/src/gcs_lock.dart

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,13 @@
55
// ignore_for_file: avoid_print
66

77
import 'package:googleapis/storage/v1.dart';
8-
import 'package:googleapis_auth/googleapis_auth.dart';
98

109
/// Global (in terms of earth) mutex using Google Cloud Storage.
1110
class GcsLock {
1211
/// Create a lock with an authenticated client and a GCS bucket name.
1312
///
1413
/// The client is used to communicate with Google Cloud Storage APIs.
15-
GcsLock(this._client, this._bucketName) {
16-
_api = StorageApi(_client);
17-
}
14+
GcsLock(this._api, this._bucketName);
1815

1916
/// Create a temporary lock file in GCS, and use it as a mutex mechanism to
2017
/// run a piece of code exclusively.
@@ -79,13 +76,28 @@ class GcsLock {
7976
}
8077

8178
Future<void> _unlock(String lockFileName) async {
82-
await _api.objects.delete(_bucketName, lockFileName);
79+
Duration waitPeriod = const Duration(milliseconds: 10);
80+
bool unlocked = false;
81+
// Retry in the case of GCS returning an API error, but rethrow if unable
82+
// to unlock after a certain period of time.
83+
while (!unlocked) {
84+
try {
85+
await _api.objects.delete(_bucketName, lockFileName);
86+
unlocked = true;
87+
} on DetailedApiRequestError {
88+
if (waitPeriod < _unlockThreshold) {
89+
await Future<void>.delayed(waitPeriod);
90+
waitPeriod *= 2;
91+
} else {
92+
rethrow;
93+
}
94+
}
95+
}
8396
}
8497

85-
late StorageApi _api;
86-
8798
final String _bucketName;
88-
final AuthClient _client;
99+
final StorageApi _api;
89100

90101
static const Duration _kWarningThreshold = Duration(seconds: 10);
102+
static const Duration _unlockThreshold = Duration(minutes: 1);
91103
}

packages/metrics_center/lib/src/skiaperf.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import 'dart:convert';
88

99
import 'package:gcloud/storage.dart';
10-
import 'package:googleapis/storage/v1.dart' show DetailedApiRequestError;
10+
import 'package:googleapis/storage/v1.dart'
11+
show DetailedApiRequestError, StorageApi;
1112
import 'package:googleapis_auth/auth_io.dart';
1213

1314
import 'common.dart';
@@ -388,7 +389,7 @@ class SkiaPerfDestination extends MetricDestination {
388389
}
389390
final SkiaPerfGcsAdaptor adaptor =
390391
SkiaPerfGcsAdaptor(storage.bucket(bucketName));
391-
final GcsLock lock = GcsLock(client, bucketName);
392+
final GcsLock lock = GcsLock(StorageApi(client), bucketName);
392393
return SkiaPerfDestination(adaptor, lock);
393394
}
394395

packages/metrics_center/pubspec.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: metrics_center
2-
version: 1.0.9
2+
version: 1.0.10
33
description:
44
Support multiple performance metrics sources/formats and destinations.
55
repository: https://github.com/flutter/packages/tree/main/packages/metrics_center
@@ -9,6 +9,7 @@ environment:
99
sdk: ">=2.18.0 <4.0.0"
1010

1111
dependencies:
12+
_discoveryapis_commons: ^1.0.0
1213
crypto: ^3.0.1
1314
equatable: ^2.0.3
1415
gcloud: ^0.8.2

packages/metrics_center/test/gcs_lock_test.dart

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ enum TestPhase {
2222
run2,
2323
}
2424

25-
@GenerateMocks(<Type>[AuthClient])
25+
@GenerateMocks(<Type>[
26+
AuthClient,
27+
StorageApi
28+
], customMocks: <MockSpec<dynamic>>[
29+
MockSpec<ObjectsResource>(onMissingStub: OnMissingStub.returnDefault)
30+
])
2631
void main() {
2732
const Duration kDelayStep = Duration(milliseconds: 10);
2833
final Map<String, dynamic>? credentialsJson = getTestGcpCredentialsJson();
@@ -36,7 +41,7 @@ void main() {
3641
Zone.current.fork(specification: spec).run<void>(() {
3742
fakeAsync((FakeAsync fakeAsync) {
3843
final MockAuthClient mockClient = MockAuthClient();
39-
final GcsLock lock = GcsLock(mockClient, 'mockBucket');
44+
final GcsLock lock = GcsLock(StorageApi(mockClient), 'mockBucket');
4045
when(mockClient.send(any)).thenThrow(DetailedApiRequestError(412, ''));
4146
final Future<void> runFinished =
4247
lock.protectedRun('mock.lock', () async {});
@@ -63,7 +68,7 @@ void main() {
6368
test('GcsLock integration test: single protectedRun is successful', () async {
6469
final AutoRefreshingAuthClient client = await clientViaServiceAccount(
6570
ServiceAccountCredentials.fromJson(credentialsJson), Storage.SCOPES);
66-
final GcsLock lock = GcsLock(client, kTestBucketName);
71+
final GcsLock lock = GcsLock(StorageApi(client), kTestBucketName);
6772
int testValue = 0;
6873
await lock.protectedRun('test.lock', () async {
6974
testValue = 1;
@@ -74,8 +79,8 @@ void main() {
7479
test('GcsLock integration test: protectedRun is exclusive', () async {
7580
final AutoRefreshingAuthClient client = await clientViaServiceAccount(
7681
ServiceAccountCredentials.fromJson(credentialsJson), Storage.SCOPES);
77-
final GcsLock lock1 = GcsLock(client, kTestBucketName);
78-
final GcsLock lock2 = GcsLock(client, kTestBucketName);
82+
final GcsLock lock1 = GcsLock(StorageApi(client), kTestBucketName);
83+
final GcsLock lock2 = GcsLock(StorageApi(client), kTestBucketName);
7984

8085
TestPhase phase = TestPhase.run1;
8186
final Completer<void> started1 = Completer<void>();
@@ -105,4 +110,39 @@ void main() {
105110
await finished1;
106111
await finished2;
107112
}, skip: credentialsJson == null);
113+
114+
test('GcsLock attempts to unlock again on a DetailedApiRequestError',
115+
() async {
116+
fakeAsync((FakeAsync fakeAsync) {
117+
final StorageApi mockStorageApi = MockStorageApi();
118+
final ObjectsResource mockObjectsResource = MockObjectsResource();
119+
final GcsLock gcsLock = GcsLock(mockStorageApi, kTestBucketName);
120+
const String lockFileName = 'test.lock';
121+
when(mockStorageApi.objects).thenReturn(mockObjectsResource);
122+
123+
// Simulate a failure to delete a lock file.
124+
when(mockObjectsResource.delete(kTestBucketName, lockFileName))
125+
.thenThrow(DetailedApiRequestError(504, ''));
126+
127+
gcsLock.protectedRun(lockFileName, () async {});
128+
129+
// Allow time to pass by to ensure deleting the lock file is retried multiple times.
130+
fakeAsync.elapse(const Duration(milliseconds: 30));
131+
verify(mockObjectsResource.delete(kTestBucketName, lockFileName))
132+
.called(3);
133+
134+
// Simulate a successful deletion of the lock file.
135+
when(mockObjectsResource.delete(kTestBucketName, lockFileName))
136+
.thenAnswer((_) => Future<void>(
137+
() {
138+
return;
139+
},
140+
));
141+
142+
// At this point, there should only be one more (successful) attempt to delete the lock file.
143+
fakeAsync.elapse(const Duration(minutes: 2));
144+
verify(mockObjectsResource.delete(kTestBucketName, lockFileName))
145+
.called(1);
146+
});
147+
});
108148
}

0 commit comments

Comments
 (0)