Skip to content

Commit b6e3d95

Browse files
authored
Refactor GoogleMediaExporter to call thirdparty getMediaItem with retrying & error skipping. (#1295)
* Test for the getGoogleMediaItem method * fixed GoogleMediaExporter constructor to take in photo interface * tweak the tests * Add a test for a set of photos with one failing * Made the test have two failures, such that we know the retrying worked and both got logged * Added todos for retrying & interface stuff * Remove VisibleForTesting on exportPhotosContainer * add nullable to the nullable fields in the constructor * Comment null fields with the field name * One more comment * Constructor signatures fixed, removed duplicate constructor * new line * reorder the other constructor
1 parent c695d54 commit b6e3d95

File tree

4 files changed

+173
-40
lines changed

4 files changed

+173
-40
lines changed

extensions/data-transfer/portability-data-transfer-google/src/main/java/org/datatransferproject/datatransfer/google/GoogleTransferExtension.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public void initialize(ExtensionContext context) {
151151
PHOTOS, new GooglePhotosExporter(credentialFactory, jobStore, jsonFactory, monitor));
152152
exporterBuilder.put(VIDEOS, new GoogleVideosExporter(credentialFactory, jsonFactory));
153153
exporterBuilder.put(
154-
MEDIA, new GoogleMediaExporter(credentialFactory, jobStore, jsonFactory, monitor, idempotentImportExecutor, enableRetrying));
154+
MEDIA, new GoogleMediaExporter(credentialFactory, jobStore, jsonFactory, monitor, /* photosInterface= */ null, idempotentImportExecutor, enableRetrying));
155155
exporterBuilder.put(MUSIC, new GoogleMusicExporter(credentialFactory, jsonFactory, monitor));
156156

157157
exporterMap = exporterBuilder.build();

extensions/data-transfer/portability-data-transfer-google/src/main/java/org/datatransferproject/datatransfer/google/media/GoogleMediaExporter.java

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package org.datatransferproject.datatransfer.google.media;
1717

18+
import static java.lang.String.format;
19+
1820
import com.fasterxml.jackson.core.JsonProcessingException;
1921
import com.fasterxml.jackson.databind.ObjectMapper;
2022
import com.google.api.client.auth.oauth2.Credential;
@@ -31,6 +33,7 @@
3133
import java.util.List;
3234
import java.util.Optional;
3335
import java.util.UUID;
36+
import javax.annotation.Nullable;
3437
import org.datatransferproject.api.launcher.Monitor;
3538
import org.datatransferproject.datatransfer.google.common.GoogleCredentialFactory;
3639
import org.datatransferproject.datatransfer.google.mediaModels.AlbumListResponse;
@@ -82,26 +85,8 @@ public GoogleMediaExporter(
8285
credentialFactory,
8386
jobStore,
8487
jsonFactory,
85-
null,
86-
monitor
87-
);
88-
}
89-
90-
public GoogleMediaExporter(
91-
GoogleCredentialFactory credentialFactory,
92-
TemporaryPerJobDataStore jobStore,
93-
JsonFactory jsonFactory,
94-
Monitor monitor,
95-
IdempotentImportExecutor retryingExecutor,
96-
boolean enableRetrying) {
97-
this(
98-
credentialFactory,
99-
jobStore,
100-
jsonFactory,
101-
null,
10288
monitor,
103-
retryingExecutor,
104-
enableRetrying
89+
/* photosInterface= */ null
10590
);
10691
}
10792

@@ -110,24 +95,27 @@ public GoogleMediaExporter(
11095
GoogleCredentialFactory credentialFactory,
11196
TemporaryPerJobDataStore jobStore,
11297
JsonFactory jsonFactory,
113-
GooglePhotosInterface photosInterface,
114-
Monitor monitor) {
98+
Monitor monitor, @Nullable
99+
GooglePhotosInterface photosInterface
100+
) {
115101
this(
116102
credentialFactory,
117103
jobStore,
118104
jsonFactory,
119-
photosInterface,
120105
monitor,
121-
null,
106+
photosInterface,
107+
/* retryingExecutor= */ null,
122108
false);
123109
}
124110

125-
GoogleMediaExporter(GoogleCredentialFactory credentialFactory,
111+
@VisibleForTesting
112+
public GoogleMediaExporter(
113+
GoogleCredentialFactory credentialFactory,
126114
TemporaryPerJobDataStore jobStore,
127115
JsonFactory jsonFactory,
128-
GooglePhotosInterface photosInterface,
129116
Monitor monitor,
130-
IdempotentImportExecutor retryingExecutor,
117+
@Nullable GooglePhotosInterface photosInterface,
118+
@Nullable IdempotentImportExecutor retryingExecutor,
131119
boolean enableRetrying) {
132120
this.credentialFactory = credentialFactory;
133121
this.jobStore = jobStore;
@@ -224,7 +212,11 @@ private ExportResult<MediaContainerResource> exportPhotosContainer(
224212

225213
for (PhotoModel photo : container.getPhotos()) {
226214
GoogleMediaItem googleMediaItem =
227-
getOrCreatePhotosInterface(authData).getMediaItem(photo.getDataId());
215+
getGoogleMediaItem(photo.getIdempotentId(), photo.getDataId(), photo.getName(), authData);
216+
if (googleMediaItem == null) {
217+
continue;
218+
}
219+
228220
photosBuilder.add(GoogleMediaItem.convertToPhotoModel(Optional.empty(), googleMediaItem));
229221
}
230222

@@ -253,14 +245,23 @@ private ExportResult<MediaContainerResource> exportMediaContainer(
253245
}
254246

255247
for (PhotoModel photo : container.getPhotos()) {
256-
GoogleMediaItem googleMediaItem =
257-
getOrCreatePhotosInterface(authData).getMediaItem(photo.getDataId());
258-
photosBuilder.add(GoogleMediaItem.convertToPhotoModel(Optional.empty(), googleMediaItem));
248+
GoogleMediaItem photoMediaItem =
249+
getGoogleMediaItem(photo.getIdempotentId(), photo.getDataId(), photo.getName(), authData);
250+
if (photoMediaItem == null) {
251+
continue;
252+
}
253+
254+
photosBuilder.add(GoogleMediaItem.convertToPhotoModel(Optional.empty(), photoMediaItem));
259255
}
260256

261257
for (VideoModel video : container.getVideos()) {
262-
GoogleMediaItem googleMediaItem = getOrCreatePhotosInterface(authData).getMediaItem(video.getDataId());
263-
videosBuilder.add(GoogleMediaItem.convertToVideoModel(Optional.empty(), googleMediaItem));
258+
GoogleMediaItem videoMediaItem =
259+
getGoogleMediaItem(video.getIdempotentId(), video.getDataId(), video.getName(), authData);
260+
if (videoMediaItem == null) {
261+
continue;
262+
}
263+
264+
videosBuilder.add(GoogleMediaItem.convertToVideoModel(Optional.empty(), videoMediaItem));
264265
}
265266

266267
MediaContainerResource mediaContainerResource =
@@ -310,7 +311,7 @@ ExportResult<MediaContainerResource> exportAlbums(
310311
albums.add(album);
311312

312313
monitor.debug(
313-
() -> String.format("%s: Google Photos exporting album: %s", jobId, album.getId()));
314+
() -> format("%s: Google Photos exporting album: %s", jobId, album.getId()));
314315

315316
// Add album id to continuation data
316317
continuationData.addContainerResource(new IdOnlyContainerResource(googleAlbum.getId()));
@@ -438,20 +439,41 @@ private MediaContainerResource convertMediaListToResource(
438439
photos.add(photoModel);
439440

440441
monitor.debug(
441-
() -> String.format("%s: Google exporting photo: %s", jobId, photoModel.getDataId()));
442+
() -> format("%s: Google exporting photo: %s", jobId, photoModel.getDataId()));
442443
}
443444
} else if (mediaItem.isVideo()) {
444445
if (shouldUpload) {
445446
VideoModel videoModel = GoogleMediaItem.convertToVideoModel(albumId, mediaItem);
446447
videos.add(videoModel);
447448
monitor.debug(
448-
() -> String.format("%s: Google exporting video: %s", jobId, videoModel.getDataId()));
449+
() -> format("%s: Google exporting video: %s", jobId, videoModel.getDataId()));
449450
}
450451
}
451452
}
452453
return new MediaContainerResource(null /*albums*/, photos, videos);
453454
}
454455

456+
//TODO(#1308): Make the retrying methods API & adaptor agnostic
457+
@VisibleForTesting
458+
@Nullable
459+
GoogleMediaItem getGoogleMediaItem(String photoIdempotentId, String photoDataId,
460+
String photoName, TokensAndUrlAuthData authData) throws IOException, InvalidTokenException, PermissionDeniedException {
461+
if (retryingExecutor == null || !enableRetrying) {
462+
return getOrCreatePhotosInterface(authData).getMediaItem(photoDataId);
463+
}
464+
465+
try {
466+
GoogleMediaItem googleMediaItem = retryingExecutor.executeAndSwallowIOExceptions(
467+
photoIdempotentId, photoName,
468+
() -> getOrCreatePhotosInterface(authData).getMediaItem(photoDataId)
469+
);
470+
return googleMediaItem;
471+
} catch (Exception e) {
472+
monitor.info(() -> format("Retry exception encountered while fetching a photo: %s", e));
473+
}
474+
return null;
475+
}
476+
455477
private synchronized GooglePhotosInterface getOrCreatePhotosInterface(
456478
TokensAndUrlAuthData authData) {
457479
return photosInterface == null ? makePhotosInterface(authData) : photosInterface;

extensions/data-transfer/portability-data-transfer-google/src/main/java/org/datatransferproject/datatransfer/google/photos/GooglePhotosInterface.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
import org.datatransferproject.spi.transfer.types.PermissionDeniedException;
6666
import org.datatransferproject.spi.transfer.types.UploadErrorException;
6767

68+
// TODO (#1307): Find a way to consolidate all 3P API interfaces
6869
public class GooglePhotosInterface {
6970

7071
public static final String ERROR_HASH_MISMATCH = "Hash mismatch";

extensions/data-transfer/portability-data-transfer-google/src/test/java/org/datatransferproject/datatransfer/google/media/GoogleMediaExporterTest.java

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static org.datatransferproject.datatransfer.google.media.GoogleMediaExporter.MEDIA_TOKEN_PREFIX;
2121

2222
import static org.junit.jupiter.api.Assertions.assertNull;
23+
import static org.junit.jupiter.api.Assertions.assertThrows;
2324
import static org.mockito.ArgumentMatchers.any;
2425
import static org.mockito.ArgumentMatchers.anyString;
2526
import static org.mockito.ArgumentMatchers.eq;
@@ -30,6 +31,7 @@
3031

3132
import com.fasterxml.jackson.databind.ObjectMapper;
3233
import com.google.api.client.json.gson.GsonFactory;
34+
import com.google.common.collect.ImmutableList;
3335
import java.io.IOException;
3436
import java.io.InputStream;
3537
import java.util.Collection;
@@ -49,24 +51,27 @@
4951
import org.datatransferproject.datatransfer.google.photos.GooglePhotosInterface;
5052
import org.datatransferproject.spi.cloud.storage.TemporaryPerJobDataStore;
5153
import org.datatransferproject.spi.cloud.storage.TemporaryPerJobDataStore.InputStreamWrapper;
54+
import org.datatransferproject.spi.transfer.idempotentexecutor.RetryingInMemoryIdempotentImportExecutor;
5255
import org.datatransferproject.spi.transfer.provider.ExportResult;
5356
import org.datatransferproject.spi.transfer.types.ContinuationData;
5457
import org.datatransferproject.spi.transfer.types.InvalidTokenException;
5558
import org.datatransferproject.spi.transfer.types.PermissionDeniedException;
5659
import org.datatransferproject.spi.transfer.types.TempMediaData;
5760
import org.datatransferproject.spi.transfer.types.UploadErrorException;
61+
import org.datatransferproject.types.common.ExportInformation;
5862
import org.datatransferproject.types.common.PaginationData;
5963
import org.datatransferproject.types.common.StringPaginationToken;
6064
import org.datatransferproject.types.common.models.ContainerResource;
6165
import org.datatransferproject.types.common.models.IdOnlyContainerResource;
6266
import org.datatransferproject.types.common.models.photos.PhotoAlbum;
6367
import org.datatransferproject.types.common.models.photos.PhotoModel;
6468
import org.datatransferproject.types.common.models.photos.PhotosContainerResource;
65-
import org.datatransferproject.types.common.models.videos.VideoAlbum;
6669
import org.datatransferproject.types.common.models.videos.VideoModel;
67-
import org.datatransferproject.types.common.models.videos.VideosContainerResource;
6870
import org.datatransferproject.types.common.models.media.MediaAlbum;
6971
import org.datatransferproject.types.common.models.media.MediaContainerResource;
72+
import org.datatransferproject.types.transfer.auth.TokensAndUrlAuthData;
73+
import org.datatransferproject.types.transfer.retry.RetryStrategyLibrary;
74+
import org.datatransferproject.types.transfer.retry.UniformRetryStrategy;
7075
import org.junit.jupiter.api.BeforeEach;
7176
import org.junit.jupiter.api.Test;
7277
import org.mockito.ArgumentCaptor;
@@ -78,9 +83,14 @@ public class GoogleMediaExporterTest {
7883
private static String ALBUM_TOKEN = "some-upstream-generated-album-token";
7984
private static String MEDIA_TOKEN = "some-upstream-generated-media-token";
8085

81-
private static UUID uuid = UUID.randomUUID();
86+
private static long RETRY_INTERVAL_MILLIS = 100L;
87+
private static int RETRY_MAX_ATTEMPTS = 1;
8288

89+
private static UUID uuid = UUID.randomUUID();
90+
private TokensAndUrlAuthData authData;
91+
private RetryingInMemoryIdempotentImportExecutor retryingExecutor;
8392
private GoogleMediaExporter googleMediaExporter;
93+
private GoogleMediaExporter retryingGoogleMediaExporter;
8494
private TemporaryPerJobDataStore jobStore;
8595
private GooglePhotosInterface photosInterface;
8696

@@ -99,10 +109,20 @@ public void setup()
99109
mediaItemSearchResponse = mock(MediaItemSearchResponse.class);
100110

101111
Monitor monitor = mock(Monitor.class);
112+
authData = mock(TokensAndUrlAuthData.class);
113+
114+
retryingExecutor = new RetryingInMemoryIdempotentImportExecutor(monitor,
115+
new RetryStrategyLibrary(ImmutableList.of(), new UniformRetryStrategy(RETRY_MAX_ATTEMPTS, RETRY_INTERVAL_MILLIS, "identifier"))
116+
);
102117

103118
googleMediaExporter =
104119
new GoogleMediaExporter(
105-
credentialFactory, jobStore, GsonFactory.getDefaultInstance(), photosInterface, monitor);
120+
credentialFactory, jobStore, GsonFactory.getDefaultInstance(), monitor, photosInterface);
121+
122+
retryingGoogleMediaExporter = new GoogleMediaExporter(
123+
credentialFactory, jobStore, GsonFactory.getDefaultInstance(), monitor, photosInterface,
124+
retryingExecutor,
125+
true);
106126

107127
when(photosInterface.listAlbums(any(Optional.class))).thenReturn(albumListResponse);
108128
when(photosInterface.listMediaItems(any(Optional.class), any(Optional.class)))
@@ -354,6 +374,92 @@ public void onlyExportAlbumlessPhoto()
354374
.containsExactly(albumlessPhotoUri + "=d"); // download
355375
}
356376

377+
@Test
378+
public void testGetGoogleMediaItemSucceeds() throws IOException, InvalidTokenException, PermissionDeniedException {
379+
String mediaItemID = "media_id";
380+
when(photosInterface.getMediaItem(any())).thenReturn(setUpSingleMediaItem(mediaItemID, mediaItemID, null));
381+
382+
assertThat(retryingGoogleMediaExporter.getGoogleMediaItem(mediaItemID, mediaItemID, mediaItemID, authData)).isInstanceOf(GoogleMediaItem.class);
383+
384+
}
385+
386+
@Test
387+
public void testExportPhotosContainerRetrying() throws IOException, InvalidTokenException, PermissionDeniedException, UploadErrorException {
388+
String PHOTO_ID_TO_FAIL_1 = "photo3";
389+
String PHOTO_ID_TO_FAIL_2 = "photo5";
390+
391+
ImmutableList<PhotoAlbum> albums = ImmutableList.of();
392+
ImmutableList<PhotoModel> photos = ImmutableList.of(
393+
setUpSinglePhotoModel("", "photo1"),
394+
setUpSinglePhotoModel("", "photo2"),
395+
setUpSinglePhotoModel("", PHOTO_ID_TO_FAIL_1),
396+
setUpSinglePhotoModel("", "photo4"),
397+
setUpSinglePhotoModel("", PHOTO_ID_TO_FAIL_2),
398+
setUpSinglePhotoModel("", "photo6")
399+
);
400+
401+
PhotosContainerResource container = new PhotosContainerResource(albums, photos);
402+
ExportInformation exportInfo = new ExportInformation(null, container);
403+
404+
MediaMetadata photoMediaMetadata = new MediaMetadata();
405+
photoMediaMetadata.setPhoto(new Photo());
406+
407+
408+
// For the photo_id_to_fail photos, throw an exception.
409+
when(photosInterface.getMediaItem(PHOTO_ID_TO_FAIL_1)).thenThrow(IOException.class);
410+
when(photosInterface.getMediaItem(PHOTO_ID_TO_FAIL_2)).thenThrow(IOException.class);
411+
// For all other photos, return a media item.
412+
for (PhotoModel photoModel: photos) {
413+
if (photoModel.getDataId().equals(PHOTO_ID_TO_FAIL_1) || photoModel.getDataId().equals(PHOTO_ID_TO_FAIL_2)) {
414+
continue;
415+
}
416+
when(photosInterface.getMediaItem(photoModel.getDataId())).thenReturn(
417+
setUpSingleMediaItem(photoModel.getDataId(), photoModel.getDataId(), photoMediaMetadata)
418+
);
419+
}
420+
421+
ExportResult<MediaContainerResource> result = retryingGoogleMediaExporter.export(
422+
uuid, authData, Optional.of(exportInfo)
423+
);
424+
assertThat(
425+
result.getExportedData().getPhotos().stream().map(x -> x.getDataId()).collect(Collectors.toList())
426+
).isEqualTo(
427+
photos.stream().map(
428+
x -> x.getDataId()
429+
).filter(
430+
dataId -> !(dataId.equals(PHOTO_ID_TO_FAIL_1) || dataId.equals(PHOTO_ID_TO_FAIL_2))
431+
).collect(
432+
Collectors.toList()
433+
)
434+
);
435+
assertThat(result.getExportedData().getPhotos().size()).isEqualTo(photos.size() - 2);
436+
assertThat(retryingExecutor.getErrors().size()).isEqualTo(2);
437+
assertThat(retryingExecutor.getErrors().stream().findFirst().toString().contains("IOException")).isTrue();
438+
}
439+
440+
@Test
441+
public void testGetGoogleMediaItemFailed() throws IOException, InvalidTokenException, PermissionDeniedException {
442+
String mediaItemID = "media_id";
443+
when(photosInterface.getMediaItem(mediaItemID)).thenThrow(IOException.class);
444+
445+
long start = System.currentTimeMillis();
446+
assertThat(retryingGoogleMediaExporter.getGoogleMediaItem(mediaItemID, mediaItemID, mediaItemID, authData)).isNull();
447+
long end = System.currentTimeMillis();
448+
449+
// If retrying occurred, then the retry_interval must have been waited at least max_attempts
450+
// amount of times.
451+
assertThat(end - start).isAtLeast(RETRY_INTERVAL_MILLIS * RETRY_MAX_ATTEMPTS);
452+
assertThat(retryingExecutor.getErrors().size()).isEqualTo(1);
453+
assertThat(retryingExecutor.getErrors().stream().findFirst().toString()).contains("IOException");
454+
455+
456+
start = System.currentTimeMillis();
457+
assertThrows(IOException.class, () -> googleMediaExporter.getGoogleMediaItem(mediaItemID, mediaItemID, mediaItemID, authData));
458+
end = System.currentTimeMillis();
459+
460+
assertThat(end - start).isLessThan(RETRY_INTERVAL_MILLIS * RETRY_MAX_ATTEMPTS);
461+
}
462+
357463
/** Sets up a response with a single album, containing a single photo */
358464
private void setUpSingleAlbum() {
359465
GoogleAlbum albumEntry = new GoogleAlbum();
@@ -363,6 +469,10 @@ private void setUpSingleAlbum() {
363469
when(albumListResponse.getAlbums()).thenReturn(new GoogleAlbum[] {albumEntry});
364470
}
365471

472+
private static PhotoModel setUpSinglePhotoModel(String albumId, String dataId) {
473+
return new PhotoModel("Title", "fetchableUrl", "description", "photo", dataId, albumId, false);
474+
}
475+
366476
/** Sets up a response for a single photo */
367477
// TODO(zacsh) delete this helper in favor of explicitly setting the fields that an assertion will
368478
// _actually_ use (and doing so _inlined, visibly_ in the arrange phase).

0 commit comments

Comments
 (0)