files.com 1.1.813 → 1.1.815
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/_VERSION +1 -1
- data/doc/Files/APIConnectionError.html +1 -1
- data/doc/Files/APIError.html +1 -1
- data/doc/Files/AccountAlreadyExistsError.html +1 -1
- data/doc/Files/AccountLineItem.html +1 -1
- data/doc/Files/AccountOverdueError.html +1 -1
- data/doc/Files/Action.html +1 -1
- data/doc/Files/ActionLog.html +1 -1
- data/doc/Files/ActionNotificationExport.html +1 -1
- data/doc/Files/ActionNotificationExportResult.html +1 -1
- data/doc/Files/AdditionalAuthenticationRequiredError.html +1 -1
- data/doc/Files/AgentNode.html +1 -1
- data/doc/Files/AgentNodeConnection.html +1 -1
- data/doc/Files/AgentNodeInstance.html +1 -1
- data/doc/Files/AgentPushUpdate.html +1 -1
- data/doc/Files/AgentPushUpdateBlockedError.html +1 -1
- data/doc/Files/AgentUnavailableError.html +1 -1
- data/doc/Files/AgentUpgradeRequiredError.html +1 -1
- data/doc/Files/AiAssistantPersonality.html +1 -1
- data/doc/Files/AiTask.html +1 -1
- data/doc/Files/AiTaskCannotBeRunManuallyError.html +1 -1
- data/doc/Files/AlreadyCompletedError.html +1 -1
- data/doc/Files/Api.html +20 -13
- data/doc/Files/ApiClient/RequestLogContext.html +29 -29
- data/doc/Files/ApiClient/TransferSettings.html +721 -0
- data/doc/Files/ApiClient.html +756 -204
- data/doc/Files/ApiKey.html +1 -1
- data/doc/Files/ApiKeyIsDisabledError.html +1 -1
- data/doc/Files/ApiKeyIsPathRestrictedError.html +1 -1
- data/doc/Files/ApiKeyNotFoundError.html +1 -1
- data/doc/Files/ApiKeyOnlyForDesktopAppError.html +1 -1
- data/doc/Files/ApiKeyOnlyForFileOperationsError.html +1 -1
- data/doc/Files/ApiKeyOnlyForMobileAppError.html +1 -1
- data/doc/Files/ApiKeyOnlyForOfficeIntegrationError.html +1 -1
- data/doc/Files/ApiKeySessionsNotSupportedError.html +1 -1
- data/doc/Files/ApiRequestLog.html +1 -1
- data/doc/Files/App.html +1 -1
- data/doc/Files/As2IncomingMessage.html +1 -1
- data/doc/Files/As2OutgoingMessage.html +1 -1
- data/doc/Files/As2Partner.html +1 -1
- data/doc/Files/As2Station.html +1 -1
- data/doc/Files/AttachmentTooLargeError.html +1 -1
- data/doc/Files/AuthenticationError.html +1 -1
- data/doc/Files/AuthenticationRequiredError.html +1 -1
- data/doc/Files/Auto.html +1 -1
- data/doc/Files/Automation.html +1 -1
- data/doc/Files/AutomationAuthoringSchema.html +1 -1
- data/doc/Files/AutomationCannotBeRunManuallyError.html +1 -1
- data/doc/Files/AutomationExecutionNode.html +1 -1
- data/doc/Files/AutomationLog.html +1 -1
- data/doc/Files/AutomationRun.html +1 -1
- data/doc/Files/AutomationsUnavailableError.html +1 -1
- data/doc/Files/BadRequestError.html +1 -1
- data/doc/Files/BandwidthSnapshot.html +1 -1
- data/doc/Files/Behavior.html +1 -1
- data/doc/Files/BehaviorNotAllowedOnRemoteServerError.html +1 -1
- data/doc/Files/BillingInformationHiddenError.html +1 -1
- data/doc/Files/BillingPermissionRequiredError.html +1 -1
- data/doc/Files/BufferedUploadDisabledForThisDestinationError.html +1 -1
- data/doc/Files/Bundle.html +1 -1
- data/doc/Files/BundleAction.html +1 -1
- data/doc/Files/BundleDownload.html +1 -1
- data/doc/Files/BundleMaximumUsesReachedError.html +1 -1
- data/doc/Files/BundleNotification.html +1 -1
- data/doc/Files/BundleOnlyAllowsPreviewsError.html +1 -1
- data/doc/Files/BundleOperationRequiresSubfolderError.html +1 -1
- data/doc/Files/BundlePath.html +1 -1
- data/doc/Files/BundlePathNotFoundError.html +1 -1
- data/doc/Files/BundlePermissionRequiredError.html +1 -1
- data/doc/Files/BundleRecipient.html +1 -1
- data/doc/Files/BundleRegistration.html +1 -1
- data/doc/Files/BundleRegistrationCodeFailedError.html +1 -1
- data/doc/Files/BundleRegistrationNotFoundError.html +1 -1
- data/doc/Files/CannotAdministerHigherLevelUserError.html +1 -1
- data/doc/Files/CannotDownloadDirectoryError.html +1 -1
- data/doc/Files/CannotLoginWhileUsingKeyError.html +1 -1
- data/doc/Files/CantActForOtherUserError.html +1 -1
- data/doc/Files/CantMoveWithMultipleLocationsError.html +1 -1
- data/doc/Files/ChatMessage.html +1 -1
- data/doc/Files/ChatSession.html +1 -1
- data/doc/Files/ChildSiteManagementPolicy.html +1 -1
- data/doc/Files/Clickwrap.html +1 -1
- data/doc/Files/CodeNotFoundError.html +1 -1
- data/doc/Files/ConfigurationLockedPathError.html +1 -1
- data/doc/Files/ContactAdminForPasswordChangeHelpError.html +1 -1
- data/doc/Files/CouldNotCreateParentError.html +1 -1
- data/doc/Files/CustomDomain.html +1 -1
- data/doc/Files/DatetimeParseError.html +1 -1
- data/doc/Files/DesktopConfigurationProfile.html +1 -1
- data/doc/Files/DestinationExistsError.html +1 -1
- data/doc/Files/DestinationFolderLimitedError.html +1 -1
- data/doc/Files/DestinationParentConflictError.html +1 -1
- data/doc/Files/DestinationParentDoesNotExistError.html +1 -1
- data/doc/Files/DestinationSameError.html +1 -1
- data/doc/Files/DestinationSiteMismatchError.html +1 -1
- data/doc/Files/DirectConnectionInfo.html +1 -1
- data/doc/Files/DnsRecord.html +1 -1
- data/doc/Files/DoesNotSupportSortingError.html +1 -1
- data/doc/Files/DuplicateShareRecipientError.html +1 -1
- data/doc/Files/EmailIncomingMessage.html +1 -1
- data/doc/Files/EmailLog.html +1 -1
- data/doc/Files/Error.html +2 -2
- data/doc/Files/Errors.html +1 -1
- data/doc/Files/EventChannel.html +1 -1
- data/doc/Files/EventDeliveryAttempt.html +1 -1
- data/doc/Files/EventRecord.html +1 -1
- data/doc/Files/EventSubscription.html +1 -1
- data/doc/Files/EventTarget.html +1 -1
- data/doc/Files/ExavaultApiRequestLog.html +1 -1
- data/doc/Files/ExceededRuntimeLimitError.html +1 -1
- data/doc/Files/Expectation.html +1 -1
- data/doc/Files/ExpectationAlreadyHasOpenWindowError.html +1 -1
- data/doc/Files/ExpectationEvaluation.html +1 -1
- data/doc/Files/ExpectationIncident.html +1 -1
- data/doc/Files/ExpectationNotManualTriggerError.html +1 -1
- data/doc/Files/ExpiredPrivateKeyError.html +1 -1
- data/doc/Files/ExpiredPublicKeyError.html +1 -1
- data/doc/Files/ExportFailureError.html +1 -1
- data/doc/Files/ExportNotReadyError.html +1 -1
- data/doc/Files/ExternalEvent.html +1 -1
- data/doc/Files/FailedToChangePasswordError.html +1 -1
- data/doc/Files/File.html +2019 -1515
- data/doc/Files/FileAction.html +1 -1
- data/doc/Files/FileActionError.html +1 -1
- data/doc/Files/FileComment.html +1 -1
- data/doc/Files/FileCommentReaction.html +1 -1
- data/doc/Files/FileLockedError.html +1 -1
- data/doc/Files/FileMigration.html +1 -1
- data/doc/Files/FileMigrationLog.html +1 -1
- data/doc/Files/FileNotFoundError.html +1 -1
- data/doc/Files/FileNotUploadedError.html +1 -1
- data/doc/Files/FilePendingProcessingError.html +1 -1
- data/doc/Files/FileProcessingErrorError.html +1 -1
- data/doc/Files/FileTooBigToDecryptError.html +1 -1
- data/doc/Files/FileTooBigToEncryptError.html +1 -1
- data/doc/Files/FileUploadNotFoundError.html +1 -1
- data/doc/Files/FileUploadPart.html +1 -1
- data/doc/Files/FileUploadedToWrongRegionError.html +1 -1
- data/doc/Files/FileUtils.html +1 -1
- data/doc/Files/FilenameTooLongError.html +1 -1
- data/doc/Files/FilesAgentFailedAuthorizationError.html +1 -1
- data/doc/Files/FilesNativeStorageOverloadedError.html +1 -1
- data/doc/Files/Folder.html +1 -1
- data/doc/Files/FolderAdminOrBillingPermissionRequiredError.html +1 -1
- data/doc/Files/FolderAdminPermissionRequiredError.html +1 -1
- data/doc/Files/FolderLockedError.html +1 -1
- data/doc/Files/FolderMustNotBeAFileError.html +1 -1
- data/doc/Files/FolderNotEmptyError.html +1 -1
- data/doc/Files/FoldersNotAllowedError.html +1 -1
- data/doc/Files/FormField.html +1 -1
- data/doc/Files/FormFieldSet.html +1 -1
- data/doc/Files/FtpActionLog.html +1 -1
- data/doc/Files/FullPermissionRequiredError.html +1 -1
- data/doc/Files/GpgKey.html +1 -1
- data/doc/Files/Group.html +1 -1
- data/doc/Files/GroupNotFoundError.html +1 -1
- data/doc/Files/GroupUser.html +1 -1
- data/doc/Files/History.html +1 -1
- data/doc/Files/HistoryExport.html +1 -1
- data/doc/Files/HistoryExportResult.html +1 -1
- data/doc/Files/HistoryPermissionRequiredError.html +1 -1
- data/doc/Files/HistoryUnavailableError.html +1 -1
- data/doc/Files/HolidayCalendar.html +1 -1
- data/doc/Files/HolidayRegion.html +1 -1
- data/doc/Files/Image.html +1 -1
- data/doc/Files/InAppAiAssistantUnavailableError.html +1 -1
- data/doc/Files/InboundS3Log.html +1 -1
- data/doc/Files/InboxNotFoundError.html +1 -1
- data/doc/Files/InboxRecipient.html +1 -1
- data/doc/Files/InboxRegistration.html +1 -1
- data/doc/Files/InboxRegistrationCodeFailedError.html +1 -1
- data/doc/Files/InboxUpload.html +1 -1
- data/doc/Files/InsufficientPermissionForParamsError.html +1 -1
- data/doc/Files/InsufficientPermissionForSiteError.html +1 -1
- data/doc/Files/IntegrationCentricProfile.html +1 -1
- data/doc/Files/InternalGeneralErrorError.html +1 -1
- data/doc/Files/InvalidBodyError.html +1 -1
- data/doc/Files/InvalidBundleCodeError.html +1 -1
- data/doc/Files/InvalidCredentialsError.html +1 -1
- data/doc/Files/InvalidCursorError.html +1 -1
- data/doc/Files/InvalidCursorTypeForSortError.html +1 -1
- data/doc/Files/InvalidEtagsError.html +1 -1
- data/doc/Files/InvalidFileTypeError.html +1 -1
- data/doc/Files/InvalidFilenameError.html +1 -1
- data/doc/Files/InvalidFilterAliasCombinationError.html +1 -1
- data/doc/Files/InvalidFilterFieldError.html +1 -1
- data/doc/Files/InvalidFilterParamError.html +1 -1
- data/doc/Files/InvalidFilterParamFormatError.html +1 -1
- data/doc/Files/InvalidFilterParamValueError.html +1 -1
- data/doc/Files/InvalidInputEncodingError.html +1 -1
- data/doc/Files/InvalidInterfaceError.html +1 -1
- data/doc/Files/InvalidOauthError.html +1 -1
- data/doc/Files/InvalidOauthProviderError.html +1 -1
- data/doc/Files/InvalidOrExpiredCodeError.html +1 -1
- data/doc/Files/InvalidParameterError.html +5 -1
- data/doc/Files/InvalidPathError.html +1 -1
- data/doc/Files/InvalidPriorityColorError.html +1 -1
- data/doc/Files/InvalidRangeError.html +1 -1
- data/doc/Files/InvalidReturnToUrlError.html +1 -1
- data/doc/Files/InvalidSearchQueryError.html +1 -1
- data/doc/Files/InvalidSessionError.html +1 -1
- data/doc/Files/InvalidSiteError.html +1 -1
- data/doc/Files/InvalidSortFieldError.html +1 -1
- data/doc/Files/InvalidSortFilterCombinationError.html +1 -1
- data/doc/Files/InvalidUploadOffsetError.html +1 -1
- data/doc/Files/InvalidUploadPartGapError.html +1 -1
- data/doc/Files/InvalidUploadPartSizeError.html +1 -1
- data/doc/Files/InvalidUsernameOrPasswordError.html +1 -1
- data/doc/Files/InvalidWorkspaceIdHeaderError.html +1 -1
- data/doc/Files/InvalidZipFileError.html +1 -1
- data/doc/Files/Invoice.html +1 -1
- data/doc/Files/InvoiceLineItem.html +1 -1
- data/doc/Files/IpAddress.html +1 -1
- data/doc/Files/KeyLifecycleRule.html +1 -1
- data/doc/Files/List.html +1 -1
- data/doc/Files/Lock.html +1 -1
- data/doc/Files/LockOperationBusyError.html +1 -1
- data/doc/Files/LockedOutError.html +1 -1
- data/doc/Files/LockoutRegionMismatchError.html +1 -1
- data/doc/Files/MetadataCategory.html +1 -1
- data/doc/Files/MetadataNotSupportedOnRemotesError.html +1 -1
- data/doc/Files/MethodNotAllowedError.html +1 -1
- data/doc/Files/MigrationInProgressError.html +1 -1
- data/doc/Files/MissingParameterError.html +1 -1
- data/doc/Files/ModelSaveErrorError.html +1 -1
- data/doc/Files/MoverAccessDeniedError.html +1 -1
- data/doc/Files/MoverPackageRequiredError.html +1 -1
- data/doc/Files/MultipleProcessingErrorsError.html +1 -1
- data/doc/Files/MultipleSortParamsNotAllowedError.html +1 -1
- data/doc/Files/MustAuthenticateWithApiKeyError.html +1 -1
- data/doc/Files/NeedAdminPermissionForInboxError.html +1 -1
- data/doc/Files/NestedNotFoundError.html +1 -1
- data/doc/Files/NoAccountForSiteError.html +1 -1
- data/doc/Files/NoValidInputParamsError.html +1 -1
- data/doc/Files/NonAdminsMustQueryByFolderOrPathError.html +1 -1
- data/doc/Files/NotAllowedToCreateBundleError.html +1 -1
- data/doc/Files/NotAuthenticatedError.html +1 -1
- data/doc/Files/NotAuthorizedError.html +1 -1
- data/doc/Files/NotEnqueuableSyncError.html +1 -1
- data/doc/Files/NotFoundError.html +1 -1
- data/doc/Files/NotImplementedError.html +1 -1
- data/doc/Files/Notification.html +1 -1
- data/doc/Files/OffsetUploadNotAllowedWithMalwareScanningError.html +1 -1
- data/doc/Files/OneTimePasswordIncorrectError.html +1 -1
- data/doc/Files/OutboundConnectionLog.html +1 -1
- data/doc/Files/PartNumberTooLargeError.html +1 -1
- data/doc/Files/Partner.html +1 -1
- data/doc/Files/PartnerChannel.html +1 -1
- data/doc/Files/PartnerChannelTemplate.html +1 -1
- data/doc/Files/PartnerConnection.html +1 -1
- data/doc/Files/PartnerSite.html +1 -1
- data/doc/Files/PartnerSiteRequest.html +1 -1
- data/doc/Files/PasswordChangeNotRequiredError.html +1 -1
- data/doc/Files/PasswordChangeRequiredError.html +1 -1
- data/doc/Files/PathCannotHaveTrailingWhitespaceError.html +1 -1
- data/doc/Files/PathTooLongError.html +1 -1
- data/doc/Files/PathUtil.html +1 -1
- data/doc/Files/Payment.html +1 -1
- data/doc/Files/PaymentLineItem.html +1 -1
- data/doc/Files/PaymentMethodErrorError.html +1 -1
- data/doc/Files/PendingWorkEvent.html +1 -1
- data/doc/Files/Permission.html +1 -1
- data/doc/Files/PlanNotFoundError.html +1 -1
- data/doc/Files/Preview.html +1 -1
- data/doc/Files/PreviewOnlyPermissionCannotDownloadError.html +1 -1
- data/doc/Files/ProcessingFailureError.html +1 -1
- data/doc/Files/PublicHostingRequestLog.html +1 -1
- data/doc/Files/PublicIpAddress.html +1 -1
- data/doc/Files/PublicKey.html +1 -1
- data/doc/Files/RateLimitedError.html +1 -1
- data/doc/Files/ReadOnlySessionError.html +1 -1
- data/doc/Files/ReadPermissionRequiredError.html +1 -1
- data/doc/Files/ReauthenticationFailedError.html +1 -1
- data/doc/Files/ReauthenticationFailedFinalError.html +1 -1
- data/doc/Files/ReauthenticationNeededActionError.html +1 -1
- data/doc/Files/ReauthenticationNeededFieldsError.html +1 -1
- data/doc/Files/ReauthenticationRateLimitedError.html +1 -1
- data/doc/Files/RecaptchaFailedError.html +1 -1
- data/doc/Files/RecipientAlreadySharedError.html +1 -1
- data/doc/Files/RemoteBandwidthSnapshot.html +1 -1
- data/doc/Files/RemoteDesktopDebugLoggingDisabledError.html +1 -1
- data/doc/Files/RemoteEntryReadOnlyError.html +1 -1
- data/doc/Files/RemoteMountBackend.html +1 -1
- data/doc/Files/RemoteServer.html +1 -1
- data/doc/Files/RemoteServerConfigurationFile.html +1 -1
- data/doc/Files/RemoteServerCredential.html +1 -1
- data/doc/Files/RemoteServerErrorError.html +1 -1
- data/doc/Files/Request.html +1 -1
- data/doc/Files/RequestBodyTooLargeError.html +1 -1
- data/doc/Files/RequestParamsContainInvalidCharacterError.html +1 -1
- data/doc/Files/RequestParamsInvalidError.html +1 -1
- data/doc/Files/RequestParamsRequiredError.html +1 -1
- data/doc/Files/ResourceBelongsToParentSiteError.html +1 -1
- data/doc/Files/ResourceLockedError.html +1 -1
- data/doc/Files/Response.html +1 -1
- data/doc/Files/Restore.html +1 -1
- data/doc/Files/RootFolderBehaviorSiteAdminRequiredError.html +1 -1
- data/doc/Files/RootFolderBehaviorSkipSiteAdminRequiredError.html +1 -1
- data/doc/Files/Schedule.html +1 -1
- data/doc/Files/ScheduledExport.html +1 -1
- data/doc/Files/ScimLog.html +1 -1
- data/doc/Files/SearchAllOnChildPathError.html +1 -1
- data/doc/Files/SearchUnavailableError.html +1 -1
- data/doc/Files/Secret.html +1 -1
- data/doc/Files/SelfManagedRequiredError.html +1 -1
- data/doc/Files/ServiceUnavailableError.html +1 -1
- data/doc/Files/Session.html +1 -1
- data/doc/Files/SettingsChange.html +1 -1
- data/doc/Files/SftpActionLog.html +1 -1
- data/doc/Files/SftpHostKey.html +1 -1
- data/doc/Files/ShareGroup.html +1 -1
- data/doc/Files/ShareGroupMember.html +1 -1
- data/doc/Files/SiemHttpDestination.html +1 -1
- data/doc/Files/SiemHttpDestinationEvent.html +1 -1
- data/doc/Files/Site.html +911 -731
- data/doc/Files/SiteAdminOrPartnerAdminPermissionRequiredError.html +1 -1
- data/doc/Files/SiteAdminOrWorkspaceAdminOrFolderAdminPermissionRequiredError.html +1 -1
- data/doc/Files/SiteAdminOrWorkspaceAdminOrPartnerAdminOrFolderAdminPermissionRequiredError.html +1 -1
- data/doc/Files/SiteAdminOrWorkspaceAdminOrPartnerAdminPermissionRequiredError.html +1 -1
- data/doc/Files/SiteAdminOrWorkspaceAdminPermissionRequiredError.html +1 -1
- data/doc/Files/SiteAdminRequiredError.html +1 -1
- data/doc/Files/SiteConfigurationError.html +1 -1
- data/doc/Files/SiteDisabledError.html +1 -1
- data/doc/Files/SiteFilesAreImmutableError.html +1 -1
- data/doc/Files/SiteNotFoundError.html +1 -1
- data/doc/Files/SiteSubdomainRedirect.html +1 -1
- data/doc/Files/SiteWasRemovedError.html +1 -1
- data/doc/Files/SizableIO.html +1 -1
- data/doc/Files/Snapshot.html +1 -1
- data/doc/Files/SsoEvent.html +1 -1
- data/doc/Files/SsoStrategy.html +1 -1
- data/doc/Files/Status.html +1 -1
- data/doc/Files/Style.html +1 -1
- data/doc/Files/SubfolderLockedError.html +1 -1
- data/doc/Files/Sync.html +1 -1
- data/doc/Files/SyncInProgressError.html +1 -1
- data/doc/Files/SyncLog.html +1 -1
- data/doc/Files/SyncRun.html +1 -1
- data/doc/Files/SyncRunLiveTransfer.html +1 -1
- data/doc/Files/SystemProfiler.html +1 -1
- data/doc/Files/TooManyConcurrentLoginsError.html +1 -1
- data/doc/Files/TooManyConcurrentRequestsError.html +1 -1
- data/doc/Files/TooManyLoginAttemptsError.html +1 -1
- data/doc/Files/TooManyRequestsError.html +1 -1
- data/doc/Files/TooManySharesError.html +1 -1
- data/doc/Files/Transfer/BufferLimitError.html +175 -0
- data/doc/Files/Transfer/ByteBudget/Reservation.html +301 -0
- data/doc/Files/Transfer/ByteBudget.html +748 -0
- data/doc/Files/Transfer/CancelledError.html +235 -0
- data/doc/Files/Transfer/Checkpoint.html +1371 -0
- data/doc/Files/Transfer/CheckpointError.html +173 -0
- data/doc/Files/Transfer/ClientTransport/ClientResponse.html +661 -0
- data/doc/Files/Transfer/ClientTransport/Reader.html +530 -0
- data/doc/Files/Transfer/ClientTransport.html +675 -0
- data/doc/Files/Transfer/Clock.html +189 -0
- data/doc/Files/Transfer/Control.html +667 -0
- data/doc/Files/Transfer/Controller/Attempt.html +465 -0
- data/doc/Files/Transfer/Controller.html +1166 -0
- data/doc/Files/Transfer/Coordinator/Timing.html +568 -0
- data/doc/Files/Transfer/Coordinator.html +534 -0
- data/doc/Files/Transfer/DownloadCoordinator/FileSink.html +976 -0
- data/doc/Files/Transfer/DownloadCoordinator/IoSink.html +1168 -0
- data/doc/Files/Transfer/DownloadCoordinator/Piece.html +875 -0
- data/doc/Files/Transfer/DownloadCoordinator.html +728 -0
- data/doc/Files/Transfer/FileSlots.html +480 -0
- data/doc/Files/Transfer/FrontendResponseError.html +235 -0
- data/doc/Files/Transfer/Holds.html +505 -0
- data/doc/Files/Transfer/HttpTransport.html +579 -0
- data/doc/Files/Transfer/Job.html +2338 -0
- data/doc/Files/Transfer/Options.html +924 -0
- data/doc/Files/Transfer/PausedError.html +319 -0
- data/doc/Files/Transfer/Policy.html +1642 -0
- data/doc/Files/Transfer/RequestPermits.html +160 -0
- data/doc/Files/Transfer/SourceChangedError.html +235 -0
- data/doc/Files/Transfer/StorageResponse.html +546 -0
- data/doc/Files/Transfer/StorageRetry.html +1098 -0
- data/doc/Files/Transfer/UploadCoordinator/Grant.html +1048 -0
- data/doc/Files/Transfer/UploadCoordinator/Part.html +1048 -0
- data/doc/Files/Transfer/UploadCoordinator/Region.html +295 -0
- data/doc/Files/Transfer/UploadCoordinator/Segments.html +299 -0
- data/doc/Files/Transfer/UploadCoordinator/Source.html +725 -0
- data/doc/Files/Transfer/UploadCoordinator/StreamSource.html +664 -0
- data/doc/Files/Transfer/UploadCoordinator.html +546 -0
- data/doc/Files/Transfer/UploadPlan.html +522 -0
- data/doc/Files/Transfer/VersionChangedError.html +235 -0
- data/doc/Files/Transfer.html +134 -0
- data/doc/Files/TrialExpiredError.html +1 -1
- data/doc/Files/TrialLockedError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationCodeAlreadySentError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationCountryBlacklistedError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationErrorError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationGeneralErrorError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationMethodUnsupportedErrorError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationRequiredError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationSetupExpiredError.html +1 -1
- data/doc/Files/TwoFactorAuthenticationUnsubscribedRecipientError.html +1 -1
- data/doc/Files/URI.html +1 -1
- data/doc/Files/UnrecognizedSortIndexError.html +1 -1
- data/doc/Files/UnsupportedCurrencyError.html +1 -1
- data/doc/Files/UnsupportedHttpResponseFormatError.html +1 -1
- data/doc/Files/UnsupportedMediaTypeError.html +1 -1
- data/doc/Files/UpdatesNotAllowedForRemotesError.html +1 -1
- data/doc/Files/UploadsUnavailableError.html +1 -1
- data/doc/Files/UsageByTopLevelDir.html +1 -1
- data/doc/Files/UsageDailySnapshot.html +1 -1
- data/doc/Files/UsageSnapshot.html +1 -1
- data/doc/Files/User.html +597 -386
- data/doc/Files/UserAdditionalEmailRecipient.html +1 -1
- data/doc/Files/UserCipherUse.html +1 -1
- data/doc/Files/UserIdInvalidError.html +1 -1
- data/doc/Files/UserIdOnUserEndpointError.html +1 -1
- data/doc/Files/UserIdWithoutSiteAdminError.html +1 -1
- data/doc/Files/UserLifecycleRule.html +1 -1
- data/doc/Files/UserNotFoundError.html +1 -1
- data/doc/Files/UserRequest.html +1 -1
- data/doc/Files/UserRequestsEnabledRequiredError.html +1 -1
- data/doc/Files/UserRequiredError.html +1 -1
- data/doc/Files/UserSecurityEvent.html +1 -1
- data/doc/Files/UserSftpClientUse.html +1 -1
- data/doc/Files/Util/LogSettings.html +1 -1
- data/doc/Files/Util.html +1 -1
- data/doc/Files/WebDavActionLog.html +1 -1
- data/doc/Files/WebhookTest.html +1 -1
- data/doc/Files/Workspace.html +1 -1
- data/doc/Files/WriteAndBundlePermissionRequiredError.html +1 -1
- data/doc/Files/WritePermissionRequiredError.html +1 -1
- data/doc/Files/ZipListEntry.html +1 -1
- data/doc/Files.html +116 -116
- data/doc/_index.html +314 -4
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +1 -1
- data/doc/index.html +1 -1
- data/doc/method_list.html +5368 -3048
- data/doc/top-level-namespace.html +1 -1
- data/docs/file.md +346 -0
- data/docs/site.md +10 -0
- data/docs/user.md +8 -0
- data/lib/files.com/api.rb +6 -1
- data/lib/files.com/api_client.rb +220 -32
- data/lib/files.com/models/file.rb +124 -17
- data/lib/files.com/models/site.rb +16 -0
- data/lib/files.com/models/user.rb +19 -0
- data/lib/files.com/transfer/budgets.rb +123 -0
- data/lib/files.com/transfer/checkpoint.rb +113 -0
- data/lib/files.com/transfer/clock.rb +14 -0
- data/lib/files.com/transfer/controller.rb +549 -0
- data/lib/files.com/transfer/download_coordinator.rb +906 -0
- data/lib/files.com/transfer/errors.rb +100 -0
- data/lib/files.com/transfer/http_transport.rb +337 -0
- data/lib/files.com/transfer/job.rb +778 -0
- data/lib/files.com/transfer/options.rb +126 -0
- data/lib/files.com/transfer/policy.rb +91 -0
- data/lib/files.com/transfer/storage_retry.rb +169 -0
- data/lib/files.com/transfer/upload_coordinator.rb +657 -0
- data/lib/files.com/transfer/upload_plan.rb +157 -0
- data/lib/files.com.rb +13 -0
- data/spec/lib/api_client_spec.rb +125 -3
- data/spec/models/file_transfer_spec.rb +53 -4
- data/spec/support/transfer_server.rb +113 -14
- data/spec/transfer/adaptive_download_spec.rb +1102 -0
- data/spec/transfer/adaptive_upload_spec.rb +1602 -0
- data/spec/transfer/concurrency_controller_spec.rb +382 -0
- data/spec/workspace_id_spec.rb +19 -14
- metadata +59 -1
|
@@ -0,0 +1,1602 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "logger"
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "../support/stubbed_api"
|
|
6
|
+
require_relative "../support/transfer_server"
|
|
7
|
+
|
|
8
|
+
# Adaptive uploads against real loopback servers: one plays the Files.com API, which the SDK's own client calls on the
|
|
9
|
+
# caller's thread, and one plays storage, which the job's own connections send parts to. Every overlap, cap, retry and
|
|
10
|
+
# renewal below is what the SDK did over those sockets; only the servers' answers are scripted.
|
|
11
|
+
RSpec.describe Files::File, "adaptive uploads" do
|
|
12
|
+
include StubbedApi
|
|
13
|
+
|
|
14
|
+
# 48 bytes: twelve 4-byte parts.
|
|
15
|
+
let(:data) { (0...48).map { |index| (97 + (index % 26)).chr }.join }
|
|
16
|
+
let(:servers) { [] }
|
|
17
|
+
|
|
18
|
+
around do |example|
|
|
19
|
+
original = [ Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id, Files.workspace_id ]
|
|
20
|
+
Files.api_key = "fixture-api-key"
|
|
21
|
+
Files.max_network_retries = 0
|
|
22
|
+
Files.session_id = "fixture-session"
|
|
23
|
+
Files.workspace_id = 7
|
|
24
|
+
example.run
|
|
25
|
+
ensure
|
|
26
|
+
Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id, Files.workspace_id = original
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
after { servers.each(&:close) }
|
|
30
|
+
|
|
31
|
+
def json(status, data)
|
|
32
|
+
body = JSON.generate(data)
|
|
33
|
+
TransferServer.response("#{status} OK", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def acknowledged(number)
|
|
37
|
+
TransferServer.response("200 OK", { "ETag" => "\"etag-#{number}\"", "Content-Length" => 0 })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Storage for the parts. With hold, each part waits until that many have been in flight at once (at most five
|
|
41
|
+
# seconds); each is answered by part, or 200 with an ETag.
|
|
42
|
+
def storage_server(hold: nil, part: nil)
|
|
43
|
+
store = TransferServer.new(concurrent: true) do |request|
|
|
44
|
+
store.wait_for_peak(hold) if hold
|
|
45
|
+
number = part_number(request)
|
|
46
|
+
part ? part.call(number, request) : acknowledged(number)
|
|
47
|
+
end
|
|
48
|
+
servers << store
|
|
49
|
+
store
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The API, made Files.base_url, for uploads to the names in names. begin_upload grants the parts asked for,
|
|
53
|
+
# partsize bytes each, at /parts/<number> on store, with what grant adds (its params name the upload's "path"); the
|
|
54
|
+
# end of the upload is answered 201, once finish, when given, returns for its name.
|
|
55
|
+
def api_server(store, partsize: 4, names: %w[report.bin], grant: ->(_number, _params) { {} }, finish: nil)
|
|
56
|
+
api = TransferServer.new(concurrent: true) do |request|
|
|
57
|
+
path = request.target.split("?").first
|
|
58
|
+
begun = path[/\A\/api\/rest\/v1\/file_actions\/begin_upload\/(.+)\z/, 1]
|
|
59
|
+
ended = path[/\A\/api\/rest\/v1\/files\/(.+)\z/, 1]
|
|
60
|
+
if names.include?(begun)
|
|
61
|
+
params = Faraday::Utils.parse_nested_query(request.body).merge("path" => begun)
|
|
62
|
+
first = Integer(params.fetch("part"))
|
|
63
|
+
json(200, (first...(first + Integer(params.fetch("parts")))).map { |number|
|
|
64
|
+
{ part_number: number, partsize: partsize, ref: "upload-ref", http_method: "PUT", upload_uri: store.url("/parts/#{number}") }.merge(grant.call(number, params))
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
elsif names.include?(ended)
|
|
68
|
+
finish&.call(ended)
|
|
69
|
+
json(201, { path: ended, type: "file" })
|
|
70
|
+
else
|
|
71
|
+
TransferServer.response("404 Not Found", { "Content-Length" => 0 })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
servers << api
|
|
75
|
+
Files.base_url = api.url("")
|
|
76
|
+
api
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def part_number(request)
|
|
80
|
+
Integer(request.target.split("?").first.split("/").last)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parts_sent(store)
|
|
84
|
+
store.requests.select { |request| request.http_method == "PUT" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def begins(api)
|
|
88
|
+
api.requests.select { |request| request.target.start_with?("/api/rest/v1/file_actions/begin_upload/") }
|
|
89
|
+
.map { |request| Faraday::Utils.parse_nested_query(request.body) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ends(api)
|
|
93
|
+
api.requests.select { |request| request.target.start_with?("/api/rest/v1/files/") }.map { |request| Faraday::Utils.parse_nested_query(request.body) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def with_local_file(content)
|
|
97
|
+
Dir.mktmpdir do |dir|
|
|
98
|
+
path = File.join(dir, "report.bin")
|
|
99
|
+
File.binwrite(path, content)
|
|
100
|
+
yield path
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def upload(path, options = {}, **keywords)
|
|
105
|
+
described_class.upload_file(path, "report.bin", options, **keywords)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe "concurrency" do
|
|
109
|
+
it "sends parts at once, never more than a fixed concurrency, and reports each on the caller's thread once" do
|
|
110
|
+
store = storage_server(hold: 4)
|
|
111
|
+
api = api_server(store)
|
|
112
|
+
reports = []
|
|
113
|
+
threads = []
|
|
114
|
+
|
|
115
|
+
with_local_file(data) do |path|
|
|
116
|
+
upload(path, progress: ->(report) { (reports << report) && (threads << Thread.current) }, transfer: { concurrency: 4 })
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
expect(store.peak_in_flight).to eq(4)
|
|
120
|
+
expect(parts_sent(store).map { |request| [ part_number(request), request.body ] }.sort)
|
|
121
|
+
.to eq((1..12).map { |number| [ number, data.byteslice((number - 1) * 4, 4) ] })
|
|
122
|
+
expect(reports.map { |report| report[:part_number] }.sort).to eq((1..12).to_a)
|
|
123
|
+
expect(reports.map { |report| report[:acknowledged_bytes] }).to eq((1..12).map { |count| count * 4 })
|
|
124
|
+
expect(reports.map { |report| [ report[:total_bytes], report[:part_bytes] ] }.uniq).to eq([ [ 48, 4 ] ])
|
|
125
|
+
expect(threads.uniq).to eq([ Thread.current ])
|
|
126
|
+
expect(begins(api).map { |params| params.values_at("parts", "with_direct_connection_info") }.uniq).to eq([ %w[5 true] ])
|
|
127
|
+
expect(ends(api).first["etags"].map { |etag| [ etag["part"], etag["etag"] ] }).to eq((1..12).map { |number| [ number.to_s, "\"etag-#{number}\"" ] })
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "starts aggressive uploads at 32 parts in flight" do
|
|
131
|
+
store = storage_server(hold: 32)
|
|
132
|
+
api_server(store)
|
|
133
|
+
|
|
134
|
+
with_local_file(data * 4) { |path| upload(path, transfer: { startup: :aggressive }) }
|
|
135
|
+
|
|
136
|
+
expect(store.peak_in_flight).to eq(32)
|
|
137
|
+
expect(parts_sent(store).size).to eq(48)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "starts default uploads with one part alone" do
|
|
141
|
+
store = storage_server
|
|
142
|
+
api_server(store)
|
|
143
|
+
|
|
144
|
+
with_local_file(data) { |path| upload(path, transfer: true) }
|
|
145
|
+
|
|
146
|
+
expect(parts_sent(store).first(2).map(&:in_flight)).to eq([ 1, 1 ])
|
|
147
|
+
expect(parts_sent(store).size).to eq(12)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "sends one part at a time when the instructions say parallel_parts: false" do
|
|
151
|
+
store = storage_server
|
|
152
|
+
api_server(store, grant: ->(_number, _params) { { parallel_parts: false } })
|
|
153
|
+
|
|
154
|
+
with_local_file(data) { |path| upload(path, transfer: { concurrency: 4 }) }
|
|
155
|
+
|
|
156
|
+
expect(store.peak_in_flight).to eq(1)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "shares one controller and what it learned between the files of a job" do
|
|
160
|
+
store = storage_server
|
|
161
|
+
api_server(store)
|
|
162
|
+
|
|
163
|
+
Files::Transfer::Job.open(startup: :aggressive) do |job|
|
|
164
|
+
2.times { with_local_file(data) { |path| upload(path, transfer: job) } }
|
|
165
|
+
|
|
166
|
+
controllers = job.diagnostics[:controllers]
|
|
167
|
+
expect(controllers.map { |controller| controller[:key] }).to eq([ [ :upload, :storage, "127.0.0.1" ] ])
|
|
168
|
+
expect(controllers.first[:decisions].count { |decision| decision[:reason] == :start }).to eq(1)
|
|
169
|
+
expect(job.held_bytes).to eq(0)
|
|
170
|
+
end
|
|
171
|
+
expect(parts_sent(store).size).to eq(24)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "uses the direct policy for a valid direct_connection_info, and the storage policy otherwise" do
|
|
175
|
+
direct = { version: 1, server_name: "agent.example.test", addresses: [ "192.0.2.1" ], direct_uri: "https://agent.example.test",
|
|
176
|
+
ca_pem: "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----\n" }
|
|
177
|
+
store = storage_server
|
|
178
|
+
[ [ direct, :direct ], [ direct.merge(version: 2), :storage ] ].each do |info, policy|
|
|
179
|
+
api_server(store, grant: ->(_number, _params) { { direct_connection_info: info } })
|
|
180
|
+
Files::Transfer::Job.open do |job|
|
|
181
|
+
with_local_file(data) { |path| upload(path, transfer: job) }
|
|
182
|
+
|
|
183
|
+
controller = job.diagnostics[:controllers].first
|
|
184
|
+
expect(controller[:policy]).to eq(policy)
|
|
185
|
+
expect(controller[:decisions].first[:to]).to eq(policy == :direct ? 4 : 1)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "keeps a caller's own client for the parts, one at a time, and says so" do
|
|
191
|
+
requests = []
|
|
192
|
+
client = stubbed_api_client do |stub|
|
|
193
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/report.bin") do |env|
|
|
194
|
+
params = Faraday::Utils.parse_nested_query(env.body)
|
|
195
|
+
requests << [ :begin ]
|
|
196
|
+
first = Integer(params.fetch("part"))
|
|
197
|
+
json_response(200, (first...(first + 5)).map { |number|
|
|
198
|
+
{ part_number: number, partsize: 4, ref: "upload-ref", http_method: "PUT", upload_uri: "https://storage.example.test/parts/#{number}" }
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
stub.put(/\A\/parts\/\d+\z/) do |env|
|
|
203
|
+
number = Integer(env.url.path.split("/").last)
|
|
204
|
+
requests << [ :part, number, env.body ]
|
|
205
|
+
[ 200, { "ETag" => "\"etag-#{number}\"" }, "" ]
|
|
206
|
+
end
|
|
207
|
+
stub.post("/api/rest/v1/files/report.bin") do |_env|
|
|
208
|
+
requests << [ :end ]
|
|
209
|
+
json_response(201, { path: "report.bin", type: "file" })
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
Files::Transfer::Job.open(concurrency: 8) do |job|
|
|
214
|
+
with_local_file("abcdefghij") { |path| upload(path, { client: client }, transfer: job) }
|
|
215
|
+
|
|
216
|
+
expect(job.diagnostics[:notes]).to include(a_string_including("one request at a time"))
|
|
217
|
+
expect(job.diagnostics[:controllers].map { |controller| controller.values_at(:policy, :limit) }).to eq([ [ :fixed, 1 ] ])
|
|
218
|
+
end
|
|
219
|
+
expect(requests).to eq([ [ :begin ], [ :part, 1, "abcd" ], [ :part, 2, "efgh" ], [ :part, 3, "ij" ], [ :end ] ])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "sends each part through a caller's client's own remote_request and logger once, retrying only as the transfer" do
|
|
223
|
+
Files.max_network_retries = 1
|
|
224
|
+
sent = Hash.new(0)
|
|
225
|
+
stubs = Faraday::Adapter::Test::Stubs.new
|
|
226
|
+
stubs.post("/api/rest/v1/file_actions/begin_upload/report.bin") do |env|
|
|
227
|
+
first = Integer(Faraday::Utils.parse_nested_query(env.body).fetch("part"))
|
|
228
|
+
[ 200, { "Content-Type" => "application/json" }, JSON.generate((first...(first + 5)).map { |number|
|
|
229
|
+
{ part_number: number, partsize: 4, ref: "upload-ref", http_method: "PUT", upload_uri: "https://storage.example.test/parts/#{number}" }
|
|
230
|
+
}
|
|
231
|
+
) ]
|
|
232
|
+
end
|
|
233
|
+
stubs.put(/\A\/parts\/\d+\z/) do |env|
|
|
234
|
+
number = Integer(env.url.path.split("/").last)
|
|
235
|
+
count = (sent[number] += 1)
|
|
236
|
+
number == 2 && count == 1 ? [ 500, {}, "" ] : [ 200, { "ETag" => "\"etag-#{number}\"" }, "" ]
|
|
237
|
+
end
|
|
238
|
+
stubs.post("/api/rest/v1/files/report.bin") { |_env| [ 201, { "Content-Type" => "application/json" }, JSON.generate({ path: "report.bin" }) ] }
|
|
239
|
+
# The client hands the API its params as a Hash; this connection form-encodes them, as the SDK's own does.
|
|
240
|
+
conn = Faraday.new do |builder|
|
|
241
|
+
builder.use Faraday::Request::UrlEncoded
|
|
242
|
+
builder.use Faraday::Response::RaiseError
|
|
243
|
+
builder.adapter :test, stubs
|
|
244
|
+
end
|
|
245
|
+
log = StringIO.new
|
|
246
|
+
hooked = Class.new(Files::ApiClient) do
|
|
247
|
+
def hooks
|
|
248
|
+
@hooks ||= []
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def remote_request(method, url, headers = {}, body = nil, &block)
|
|
252
|
+
hooks << [ method, URI(url).path ]
|
|
253
|
+
super
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
client = hooked.new(conn, logger: Logger.new(log))
|
|
257
|
+
|
|
258
|
+
with_local_file("abcdefghij") { |path| upload(path, { client: client }, transfer: { concurrency: 4 }) }
|
|
259
|
+
|
|
260
|
+
expect(sent).to eq({ 1 => 1, 2 => 2, 3 => 1 })
|
|
261
|
+
expect(client.hooks.sort).to eq([ [ :put, "/parts/1" ], [ :put, "/parts/2" ], [ :put, "/parts/2" ], [ :put, "/parts/3" ] ])
|
|
262
|
+
expect(log.string).to include("[transfer]")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it "sends through the SDK's own kind of connection when its TLS or adapter settings were changed, keeping them" do
|
|
266
|
+
store = storage_server
|
|
267
|
+
api_server(store)
|
|
268
|
+
configured = []
|
|
269
|
+
hooked = Files::ApiClient.build_default_conn
|
|
270
|
+
hooked.adapter(:net_http) { |http| configured << http.port }
|
|
271
|
+
restricted = Files::ApiClient.build_default_conn
|
|
272
|
+
restricted.ssl.min_version = :TLS1_2
|
|
273
|
+
|
|
274
|
+
[ hooked, restricted ].each do |conn|
|
|
275
|
+
Files::Transfer::Job.open(concurrency: 4) do |job|
|
|
276
|
+
with_local_file(data.byteslice(0, 8)) { |path| upload(path, { client: Files::ApiClient.new(conn) }, transfer: job) }
|
|
277
|
+
|
|
278
|
+
expect(job.diagnostics[:notes]).to include(a_string_including("one request at a time"))
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
expect(configured).to include(URI(store.url("")).port)
|
|
282
|
+
expect(parts_sent(store).size).to eq(4)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it "keeps every transfer of a job within one request limit, across destinations" do
|
|
286
|
+
direct = { version: 1, server_name: "agent.example.test", addresses: [ "192.0.2.1" ], direct_uri: "https://agent.example.test",
|
|
287
|
+
ca_pem: "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----\n" }
|
|
288
|
+
store = storage_server(hold: 3)
|
|
289
|
+
api_server(store, names: %w[report.bin other.bin], grant: ->(_number, params) { params["path"] == "other.bin" ? { direct_connection_info: direct } : {} })
|
|
290
|
+
|
|
291
|
+
Files::Transfer::Job.open(startup: :aggressive, max_concurrency: 3, max_files: 2) do |job|
|
|
292
|
+
with_local_file(data) do |path|
|
|
293
|
+
%w[report.bin other.bin].map { |name| Thread.new { described_class.upload_file(path, name, {}, transfer: job) } }.each(&:join)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
diagnostics = job.diagnostics
|
|
297
|
+
expect(diagnostics[:controllers].map { |controller| controller[:policy] }.sort).to eq(%i[direct storage])
|
|
298
|
+
expect(diagnostics.values_at(:request_limit, :requests, :accepted_bytes)).to eq([ 3, 0, 96 ])
|
|
299
|
+
end
|
|
300
|
+
expect(store.peak_in_flight).to eq(3)
|
|
301
|
+
expect(parts_sent(store).size).to eq(24)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it "wakes a transfer waiting for buffer bytes as soon as another settles them, not only when it finishes" do
|
|
305
|
+
Files.max_network_retries = 1
|
|
306
|
+
lock = Mutex.new
|
|
307
|
+
failed = false
|
|
308
|
+
finishing = Queue.new
|
|
309
|
+
# report.bin is one 32 KiB part, answered 500 once and then, a moment later, acknowledged; other.bin is a 32 KiB
|
|
310
|
+
# part acknowledged at once, then a 64 KiB part that needs the whole 64 KiB budget.
|
|
311
|
+
store = storage_server(part: lambda { |number, request|
|
|
312
|
+
first = request.body.start_with?("a")
|
|
313
|
+
next TransferServer.response("500 Internal Server Error", { "Content-Length" => 0 }) if lock.synchronize { first && !failed && (failed = true) }
|
|
314
|
+
|
|
315
|
+
sleep 0.3 if first
|
|
316
|
+
acknowledged(number)
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
# report.bin's end waits, so it cannot finish and wake everything that way.
|
|
320
|
+
api_server(store, names: %w[report.bin other.bin], grant: ->(number, _params) { { partsize: number == 1 ? 32_768 : 65_536 } },
|
|
321
|
+
finish: ->(name) { finishing.pop if name == "report.bin" }
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
Dir.mktmpdir do |dir|
|
|
325
|
+
paths = { "report.bin" => "a" * 32_768, "other.bin" => "b" * 98_304 }.map { |name, content| File.join(dir, name).tap { |path| File.binwrite(path, content) } }
|
|
326
|
+
Files::Transfer::Job.open(concurrency: 2, buffer_bytes: 65_536, max_files: 2) do |job|
|
|
327
|
+
retried, waiting = paths.map { |path| Thread.new { described_class.upload_file(path, File.basename(path), {}, transfer: job) } }
|
|
328
|
+
|
|
329
|
+
expect(waiting.join(10)).to be_truthy
|
|
330
|
+
finishing << :go
|
|
331
|
+
expect(retried.join(10)).to be_truthy
|
|
332
|
+
expect(job.held_bytes).to eq(0)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
expect(parts_sent(store).size).to eq(4)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
it "keeps the origin, credentials, client, workspace, options and request settings it began with, whatever a callback changes" do
|
|
339
|
+
store = storage_server
|
|
340
|
+
api = api_server(store)
|
|
341
|
+
options = { base_url: +api.url("") }
|
|
342
|
+
unusable = Files::ApiClient.new(Faraday.new { |builder| builder.adapter :test, Faraday::Adapter::Test::Stubs.new })
|
|
343
|
+
settings = [ Files.default_headers, Files.language, Files.app_info, Files.read_timeout ]
|
|
344
|
+
Files.default_headers = { "X-Fixture-Default" => +"first" }
|
|
345
|
+
changed = lambda do |_report|
|
|
346
|
+
options[:base_url].replace("http://127.0.0.1:1")
|
|
347
|
+
options[:session_id] = "mutated-session"
|
|
348
|
+
Files.default_headers["X-Fixture-Default"].replace("mutated")
|
|
349
|
+
Files.default_headers = { "X-Fixture-Default" => "changed" }
|
|
350
|
+
Files.language = "changed"
|
|
351
|
+
Files.app_info = { name: "changed" }
|
|
352
|
+
Files.read_timeout = 0.001
|
|
353
|
+
Files.base_url = "http://127.0.0.1:1"
|
|
354
|
+
Files.session_id = "changed-session"
|
|
355
|
+
Files.api_key = "changed-api-key"
|
|
356
|
+
Files.workspace_id = 9
|
|
357
|
+
Thread.current[:files_api_client] = unusable
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
begin
|
|
361
|
+
with_local_file(data.byteslice(0, 24)) { |path| upload(path, options, progress: changed, transfer: { concurrency: 1 }) }
|
|
362
|
+
ensure
|
|
363
|
+
Thread.current[:files_api_client] = nil
|
|
364
|
+
Files.default_headers, Files.language, Files.app_info = settings
|
|
365
|
+
Files.read_timeout = settings.last
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
expect(begins(api).size).to eq(2)
|
|
369
|
+
expect(api.requests.map { |request| request.headers.values_at("x-filesapi-auth", "x-filesapi-key", "x-files-workspace-id", "x-fixture-default", "accept-language") }.uniq)
|
|
370
|
+
.to eq([ [ "fixture-session", nil, "7", "first", nil ] ])
|
|
371
|
+
expect(api.requests.map { |request| request.headers["user-agent"] }.grep(/changed/)).to be_empty
|
|
372
|
+
expect(ends(api).size).to eq(1)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "keeps the regional origin its first grant moved it to, and only that, for every later request" do
|
|
376
|
+
original_pairs = Files.region_http_pairs
|
|
377
|
+
# A regional continuation consumes one retry from this API request's shared budget.
|
|
378
|
+
Files.max_network_retries = 1
|
|
379
|
+
store = storage_server
|
|
380
|
+
regional = api_server(store)
|
|
381
|
+
home = TransferServer.new(concurrent: true) do |_request|
|
|
382
|
+
body = JSON.generate({ error: "Lockout region mismatch", type: "not-authenticated/lockout-region-mismatch", data: { host: regional.url("").delete_prefix("http://") } })
|
|
383
|
+
TransferServer.response("403 Forbidden", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
|
|
384
|
+
end
|
|
385
|
+
servers << home
|
|
386
|
+
Files.region_http_pairs = [ [ home.url(""), regional.url("") ] ]
|
|
387
|
+
Files.base_url = home.url("")
|
|
388
|
+
moved = lambda do |_report|
|
|
389
|
+
Files.base_url = "http://127.0.0.1:1"
|
|
390
|
+
Files.region_http_pairs = []
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
begin
|
|
394
|
+
with_local_file(data.byteslice(0, 24)) { |path| upload(path, progress: moved, transfer: { concurrency: 1 }) }
|
|
395
|
+
ensure
|
|
396
|
+
Files.region_http_pairs = original_pairs
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
expect(home.requests.size).to eq(1)
|
|
400
|
+
expect(begins(regional).size).to eq(2)
|
|
401
|
+
expect(ends(regional).size).to eq(1)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
it "keeps the region policy and handler it began with for a move a later request meets" do
|
|
405
|
+
originals = [ Files.region_http_pairs, Files.region_policy, Files.region_handler ]
|
|
406
|
+
Files.max_network_retries = 1
|
|
407
|
+
store = storage_server
|
|
408
|
+
third = api_server(store)
|
|
409
|
+
mismatch = lambda do |target|
|
|
410
|
+
body = JSON.generate({ error: "Lockout region mismatch", type: "not-authenticated/lockout-region-mismatch", data: { host: target.url("").delete_prefix("http://") } })
|
|
411
|
+
TransferServer.response("403 Forbidden", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
|
|
412
|
+
end
|
|
413
|
+
# The regional origin grants the parts, then moves the upload's end on to a third origin.
|
|
414
|
+
regional = TransferServer.new(concurrent: true) do |request|
|
|
415
|
+
next mismatch.call(third) unless request.target.start_with?("/api/rest/v1/file_actions/begin_upload/")
|
|
416
|
+
|
|
417
|
+
first = Integer(Faraday::Utils.parse_nested_query(request.body).fetch("part"))
|
|
418
|
+
json(200, (first...(first + 5)).map { |number|
|
|
419
|
+
{ part_number: number, partsize: 4, ref: "upload-ref", http_method: "PUT", upload_uri: store.url("/parts/#{number}") }
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
home = TransferServer.new(concurrent: true) { |_request| mismatch.call(regional) }
|
|
424
|
+
servers.push(regional, home)
|
|
425
|
+
approved = []
|
|
426
|
+
Files.region_http_pairs = [ [ home.url(""), regional.url("") ], [ regional.url(""), third.url("") ] ]
|
|
427
|
+
Files.region_handler = lambda do |event|
|
|
428
|
+
approved << event[:target_origin]
|
|
429
|
+
true
|
|
430
|
+
end
|
|
431
|
+
Files.base_url = home.url("")
|
|
432
|
+
changed = lambda do |_report|
|
|
433
|
+
Files.region_policy = :disabled
|
|
434
|
+
Files.region_handler = ->(_event) { raise "the region handler changed" }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
begin
|
|
438
|
+
with_local_file(data.byteslice(0, 8)) { |path| upload(path, progress: changed, transfer: { concurrency: 1 }) }
|
|
439
|
+
ensure
|
|
440
|
+
Files.region_http_pairs, Files.region_policy, Files.region_handler = originals
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
expect(approved).to eq([ regional.url(""), third.url("") ])
|
|
444
|
+
expect(begins(regional).size).to eq(1)
|
|
445
|
+
expect(ends(third).size).to eq(1)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
[ { transfer: { concurrency: 2 } }, {} ].each do |keywords|
|
|
449
|
+
it "uploads a record #{keywords.empty? ? 'one part at a time' : 'adaptively'} to its own path with its own options, leaving the caller's file open" do
|
|
450
|
+
store = storage_server
|
|
451
|
+
api = api_server(store)
|
|
452
|
+
record = described_class.new({ path: "report.bin", size: 999, download_uri: "https://storage.example.test/report.bin?X-Amz-Signature=secret",
|
|
453
|
+
mkdir_parents: true }, { session_id: "record-session" }
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
Dir.mktmpdir do |dir|
|
|
457
|
+
local_path = File.join(dir, "local.bin")
|
|
458
|
+
File.binwrite(local_path, data.byteslice(0, 8))
|
|
459
|
+
File.open(local_path, "rb") do |local|
|
|
460
|
+
record.upload_file(local, **keywords)
|
|
461
|
+
expect(local).not_to be_closed
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
expect(begins(api).map { |params| params.values_at("mkdir_parents", "size") }.uniq).to eq([ %w[true 8] ])
|
|
466
|
+
expect(api.requests.map { |request| request.headers["x-filesapi-auth"] }.uniq).to eq([ "record-session" ])
|
|
467
|
+
expect(api.requests.map(&:body).join).not_to include("secret")
|
|
468
|
+
expect(ends(api).size).to eq(1)
|
|
469
|
+
expect(parts_sent(store).map { |request| [ part_number(request), request.body ] }.sort).to eq([ [ 1, "abcd" ], [ 2, "efgh" ] ])
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
it "uploads exactly size bytes of a caller's stream, however short its reads and though they reuse one String, retrying a part with the same bytes" do
|
|
474
|
+
Files.max_network_retries = 1
|
|
475
|
+
lock = Mutex.new
|
|
476
|
+
attempts = Hash.new(0)
|
|
477
|
+
store = storage_server(hold: 2, part: lambda { |number, _request|
|
|
478
|
+
count = lock.synchronize { attempts[number] += 1 }
|
|
479
|
+
number == 2 && count == 1 ? TransferServer.response("500 Internal Server Error", { "Content-Length" => 0 }) : acknowledged(number)
|
|
480
|
+
}
|
|
481
|
+
)
|
|
482
|
+
api = api_server(store)
|
|
483
|
+
source = StringIO.new("#{data}tail")
|
|
484
|
+
buffer = +""
|
|
485
|
+
stream = Object.new
|
|
486
|
+
# Every read gives back the same String with its bytes replaced, as a wrapper with one buffer of its own does, both
|
|
487
|
+
# within a part and across the parts read ahead of their sends.
|
|
488
|
+
stream.define_singleton_method(:read) { |length| buffer.replace(source.read([ length, 3 ].min)) }
|
|
489
|
+
reports = []
|
|
490
|
+
|
|
491
|
+
described_class.upload_io(stream, "report.bin", {}, size: 48, progress: ->(report) { reports << report }, transfer: { concurrency: 4 })
|
|
492
|
+
|
|
493
|
+
expect(store.peak_in_flight).to be >= 2
|
|
494
|
+
expect(parts_sent(store).map { |request| [ part_number(request), request.body ] }.uniq.sort)
|
|
495
|
+
.to eq((1..12).map { |number| [ number, data.byteslice((number - 1) * 4, 4) ] })
|
|
496
|
+
expect(attempts[2]).to eq(2)
|
|
497
|
+
expect(source.pos).to eq(48)
|
|
498
|
+
expect(source).not_to be_closed
|
|
499
|
+
expect(buffer).not_to be_frozen
|
|
500
|
+
expect(reports.map { |report| report[:total_bytes] }.uniq).to eq([ 48 ])
|
|
501
|
+
expect(ends(api).first.values_at("size")).to eq([ "48" ])
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
it "stops a stream that ends before its size, without ending the upload" do
|
|
505
|
+
store = storage_server
|
|
506
|
+
api = api_server(store)
|
|
507
|
+
|
|
508
|
+
expect { described_class.upload_io(StringIO.new(data.byteslice(0, 10)), "report.bin", {}, size: 12, transfer: { concurrency: 1 }) }
|
|
509
|
+
.to raise_error(Files::Transfer::SourceChangedError)
|
|
510
|
+
# The three parts granted at once were read, in order, before any was sent.
|
|
511
|
+
expect(parts_sent(store)).to be_empty
|
|
512
|
+
expect(ends(api)).to be_empty
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
it "never holds more of a caller's stream than buffer_bytes, even while an earlier part renews its URL" do
|
|
516
|
+
lock = Mutex.new
|
|
517
|
+
acknowledged_bytes = 0
|
|
518
|
+
store = storage_server(part: lambda { |number, request|
|
|
519
|
+
lock.synchronize { acknowledged_bytes += request.body.bytesize }
|
|
520
|
+
acknowledged(number)
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
# Part 1's first grant has already expired, so it renews before it is sent while the parts after it wait.
|
|
524
|
+
api = api_server(store, partsize: 32_768, grant: lambda { |number, params|
|
|
525
|
+
number == 1 && params["parts"] == "5" ? { upload_uri: store.url("/parts/1?X-Amz-Date=20000101T000000Z&X-Amz-Expires=60") } : {}
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
content = Random.new(3).bytes(6 * 32_768)
|
|
529
|
+
source = StringIO.new(content)
|
|
530
|
+
most_unacknowledged = 0
|
|
531
|
+
stream = Object.new
|
|
532
|
+
stream.define_singleton_method(:read) do |length|
|
|
533
|
+
chunk = source.read([ length, 5000 ].min)
|
|
534
|
+
lock.synchronize { most_unacknowledged = [ most_unacknowledged, source.pos - acknowledged_bytes ].max }
|
|
535
|
+
chunk
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
Files::Transfer::Job.open(concurrency: 4, buffer_bytes: 65_536) do |job|
|
|
539
|
+
described_class.upload_io(stream, "report.bin", {}, size: content.bytesize, transfer: job)
|
|
540
|
+
expect(job.held_bytes).to eq(0)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
expect(most_unacknowledged).to be <= 65_536
|
|
544
|
+
expect(parts_sent(store).map { |request| [ part_number(request), request.body ] }.sort)
|
|
545
|
+
.to eq((1..6).map { |number| [ number, content.byteslice((number - 1) * 32_768, 32_768) ] })
|
|
546
|
+
expect(begins(api).map { |params| params.values_at("part", "parts") }).to include(%w[1 1])
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
it "gives a failed stream's buffer back only with its bytes, while another transfer of the job goes on" do
|
|
550
|
+
store = storage_server(part: lambda { |number, request|
|
|
551
|
+
next TransferServer.response("501 Not Implemented", { "Content-Length" => 0 }) if number == 2 && request.body.start_with?("s")
|
|
552
|
+
|
|
553
|
+
acknowledged(number)
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
api = api_server(store, partsize: 16_384, names: %w[report.bin other.bin])
|
|
557
|
+
reports = []
|
|
558
|
+
|
|
559
|
+
Files::Transfer::Job.open(concurrency: 2, buffer_bytes: 65_536, max_files: 2) do |job|
|
|
560
|
+
with_local_file("f" * 65_536) do |path|
|
|
561
|
+
failing = Thread.new { described_class.upload_io(StringIO.new("s" * 65_536), "other.bin", {}, size: 65_536, transfer: job) }
|
|
562
|
+
described_class.upload_file(path, "report.bin", {}, progress: ->(report) { reports << report }, transfer: job)
|
|
563
|
+
|
|
564
|
+
expect { failing.join(10) }.to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(501) }
|
|
565
|
+
end
|
|
566
|
+
expect(job.held_bytes).to eq(0)
|
|
567
|
+
# The job also counted what storage acknowledged of the other transfer, at least the part before its refusal.
|
|
568
|
+
expect(job.diagnostics[:accepted_bytes]).to be >= 65_536 + 16_384
|
|
569
|
+
end
|
|
570
|
+
expect(ends(api).map { |params| params["size"] }).to eq([ "65536" ])
|
|
571
|
+
# report.bin's progress counts its own parts alone.
|
|
572
|
+
expect(reports.map { |report| report[:acknowledged_bytes] }).to eq([ 16_384, 32_768, 49_152, 65_536 ])
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
it "reports a transfer of a shared job by its own parts and the seconds since its own call began, while the job also counts another's" do
|
|
576
|
+
clock = controlled_clock
|
|
577
|
+
other_started = Queue.new
|
|
578
|
+
other_finished = Queue.new
|
|
579
|
+
# report.bin's first part is answered only once other.bin, begun as that part reached storage, has finished. Each
|
|
580
|
+
# of report.bin's parts takes two seconds of the example's clock, and other.bin's none.
|
|
581
|
+
store = storage_server(part: lambda { |number, request|
|
|
582
|
+
next acknowledged(number) unless request.body.start_with?("f")
|
|
583
|
+
|
|
584
|
+
if number == 1
|
|
585
|
+
other_started << :go
|
|
586
|
+
other_finished.pop
|
|
587
|
+
end
|
|
588
|
+
clock.now += 2
|
|
589
|
+
acknowledged(number)
|
|
590
|
+
}
|
|
591
|
+
)
|
|
592
|
+
# report.bin sends one part at a time, which leaves the job's other request for other.bin.
|
|
593
|
+
api_server(store, names: %w[report.bin other.bin], grant: ->(_number, params) { params["path"] == "report.bin" ? { parallel_parts: false } : {} })
|
|
594
|
+
control = Files::Transfer::Control.new
|
|
595
|
+
reports = []
|
|
596
|
+
counted = []
|
|
597
|
+
other_failure = nil
|
|
598
|
+
|
|
599
|
+
Files::Transfer::Job.open(concurrency: 2, control: control) do |job|
|
|
600
|
+
# report.bin's upload begins a minute after the job.
|
|
601
|
+
clock.now += 60
|
|
602
|
+
progress = lambda do |report|
|
|
603
|
+
reports << report
|
|
604
|
+
counted << job.diagnostics.values_at(:accepted_bytes, :elapsed_seconds)
|
|
605
|
+
end
|
|
606
|
+
# other.bin's caller uploads only once storage says go, keeps what it raises, and however it ends lets go the
|
|
607
|
+
# storage answer waiting for it.
|
|
608
|
+
other = Thread.new do
|
|
609
|
+
described_class.upload_io(StringIO.new("s" * 8), "other.bin", {}, size: 8, transfer: job) if other_started.pop == :go
|
|
610
|
+
rescue StandardError => e
|
|
611
|
+
other_failure = e
|
|
612
|
+
ensure
|
|
613
|
+
other_finished << :done
|
|
614
|
+
end
|
|
615
|
+
begin
|
|
616
|
+
with_local_file("f" * 8) { |path| described_class.upload_file(path, "report.bin", {}, progress: progress, transfer: job) }
|
|
617
|
+
ensure
|
|
618
|
+
# However report.bin's upload ended, even before storage said go, other.bin's caller is let go, its upload
|
|
619
|
+
# cancelled, which one that finished ignores, and the caller joined before the job closes and the clock's stub
|
|
620
|
+
# and the servers go. None of it needs the clock to move: the cancellation wakes the upload at once, and the
|
|
621
|
+
# job, with nothing left running, closes without waiting out its deadline. What the caller raised is kept, so
|
|
622
|
+
# report.bin's own failure stays the one reported.
|
|
623
|
+
other_started << :stop
|
|
624
|
+
control.cancel
|
|
625
|
+
other.join
|
|
626
|
+
end
|
|
627
|
+
raise other_failure if other_failure
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# As each report came, the job had counted other.bin's 8 bytes as well, over the minute before report.bin began;
|
|
631
|
+
# the reports count only report.bin's own, 4 every 2 seconds since its own call began.
|
|
632
|
+
expect(counted).to eq([ [ 12, 62.0 ], [ 16, 64.0 ] ])
|
|
633
|
+
expect(reports.map { |report| report.values_at(:part_number, :acknowledged_bytes, :elapsed_seconds, :bytes_per_second) })
|
|
634
|
+
.to eq([ [ 1, 4, 2.0, 2.0 ], [ 2, 8, 4.0, 2.0 ] ])
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
it "holds a stream part read in pieces with the slice it is sent through, and one a single read gave whole with its own bytes alone" do
|
|
638
|
+
job = nil
|
|
639
|
+
source = nil
|
|
640
|
+
sending = []
|
|
641
|
+
store = storage_server(part: lambda { |number, _request|
|
|
642
|
+
sending << [ job.held_bytes, source.pos ]
|
|
643
|
+
acknowledged(number)
|
|
644
|
+
}
|
|
645
|
+
)
|
|
646
|
+
api_server(store, partsize: 65_536)
|
|
647
|
+
content = Random.new(17).bytes(2 * 65_536)
|
|
648
|
+
source = StringIO.new(content)
|
|
649
|
+
reads = 0
|
|
650
|
+
stream = Object.new
|
|
651
|
+
# The first part comes in two reads, each longer than a send's slice of it; the second in one.
|
|
652
|
+
stream.define_singleton_method(:read) { |length| source.read((reads += 1) == 1 ? 40_000 : length) }
|
|
653
|
+
|
|
654
|
+
Files::Transfer::Job.open(concurrency: 1, buffer_bytes: 131_072) do |opened|
|
|
655
|
+
job = opened
|
|
656
|
+
described_class.upload_io(stream, "report.bin", {}, size: content.bytesize, transfer: job)
|
|
657
|
+
expect(job.held_bytes).to eq(0)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# The first part held its 64 KiB and a 64 KiB slice, the whole budget, so the second was read only once storage
|
|
661
|
+
# had it; the second then held only its own 64 KiB.
|
|
662
|
+
expect(sending).to eq([ [ 131_072, 65_536 ], [ 65_536, 131_072 ] ])
|
|
663
|
+
expect(parts_sent(store).map { |request| [ part_number(request), request.body ] }.sort)
|
|
664
|
+
.to eq([ [ 1, content.byteslice(0, 65_536) ], [ 2, content.byteslice(65_536, 65_536) ] ])
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
it "refuses, before reading anything, a stream part a caller's client could not take as one String within buffer_bytes, and holds one that fits by its own bytes once read" do
|
|
668
|
+
sent = []
|
|
669
|
+
held = []
|
|
670
|
+
job = nil
|
|
671
|
+
client = lambda do |partsize|
|
|
672
|
+
stubbed_api_client do |stub|
|
|
673
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/report.bin") do |env|
|
|
674
|
+
first = Integer(Faraday::Utils.parse_nested_query(env.body).fetch("part"))
|
|
675
|
+
json_response(200, (first...(first + 5)).map { |number|
|
|
676
|
+
{ part_number: number, partsize: partsize, ref: "upload-ref", http_method: "PUT", upload_uri: "https://storage.example.test/parts/#{number}" }
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
end
|
|
680
|
+
stub.put(/\A\/parts\/\d+\z/) do |env|
|
|
681
|
+
sent << env.body
|
|
682
|
+
held << job&.held_bytes
|
|
683
|
+
[ 200, { "ETag" => "\"etag-#{sent.size}\"" }, "" ]
|
|
684
|
+
end
|
|
685
|
+
stub.post("/api/rest/v1/files/report.bin") { |_env| json_response(201, { path: "report.bin", type: "file" }) }
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
content = Random.new(13).bytes(40_000)
|
|
689
|
+
|
|
690
|
+
# Copying a 40,000-byte part read in pieces into one String could need 80,000 bytes of a 64 KiB budget.
|
|
691
|
+
large = StringIO.new(content)
|
|
692
|
+
expect { described_class.upload_io(large, "report.bin", { client: client.call(40_000) }, size: content.bytesize, transfer: { buffer_bytes: 65_536 }) }
|
|
693
|
+
.to raise_error(Files::Transfer::BufferLimitError)
|
|
694
|
+
expect(large.pos).to eq(0)
|
|
695
|
+
expect(sent).to be_empty
|
|
696
|
+
|
|
697
|
+
source = StringIO.new(content)
|
|
698
|
+
stream = Object.new
|
|
699
|
+
stream.define_singleton_method(:read) { |length| source.read([ length, 5000 ].min) }
|
|
700
|
+
Files::Transfer::Job.open(buffer_bytes: 65_536) do |opened|
|
|
701
|
+
job = opened
|
|
702
|
+
described_class.upload_io(stream, "report.bin", { client: client.call(20_000) }, size: content.bytesize, transfer: job)
|
|
703
|
+
end
|
|
704
|
+
# Each 20,000-byte part, read in pieces, reserved 40,000 bytes to be copied into one String and then held its own
|
|
705
|
+
# 20,000, so both were read before the first was sent.
|
|
706
|
+
expect(held).to eq([ 40_000, 20_000 ])
|
|
707
|
+
expect(sent).to eq([ content.byteslice(0, 20_000), content.byteslice(20_000, 20_000) ])
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Stands a clock the example moves by hand in for the monotonic clock, on every thread. It stands still, so even a
|
|
712
|
+
# retry's delay passes only as the example, or a server answering for it, moves it, and it starts far from zero, so
|
|
713
|
+
# the seconds since a call began never equal its reading.
|
|
714
|
+
def controlled_clock
|
|
715
|
+
clock = Struct.new(:now).new(1000.0)
|
|
716
|
+
allow(Files::Transfer::Clock).to receive(:now) { clock.now }
|
|
717
|
+
clock
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
describe "acknowledgement and retries" do
|
|
721
|
+
it "refuses a success without an ETag, reporting nothing and not ending the upload" do
|
|
722
|
+
store = storage_server(part: ->(_number, _request) { TransferServer.response("200 OK", { "Content-Length" => 0 }) })
|
|
723
|
+
api = api_server(store)
|
|
724
|
+
reports = []
|
|
725
|
+
|
|
726
|
+
with_local_file(data) do |path|
|
|
727
|
+
expect { upload(path, progress: ->(report) { reports << report }, transfer: { concurrency: 1 }) }
|
|
728
|
+
.to raise_error(Files::APIError, "Transfer response did not acknowledge the part") { |error| expect(error.http_status).to eq(200) }
|
|
729
|
+
end
|
|
730
|
+
expect(reports).to be_empty
|
|
731
|
+
expect(parts_sent(store).size).to eq(1)
|
|
732
|
+
expect(ends(api)).to be_empty
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
it "retries a part answered 500 with exactly its bytes, and reports it once" do
|
|
736
|
+
Files.max_network_retries = 2
|
|
737
|
+
clock = controlled_clock
|
|
738
|
+
lock = Mutex.new
|
|
739
|
+
attempts = Hash.new(0)
|
|
740
|
+
# Storage refuses part 2 at once the first time, and takes two seconds of the example's clock to acknowledge a part.
|
|
741
|
+
store = storage_server(part: lambda { |number, _request|
|
|
742
|
+
count = lock.synchronize { attempts[number] += 1 }
|
|
743
|
+
next TransferServer.response("500 Internal Server Error", { "Content-Length" => 0 }) if number == 2 && count == 1
|
|
744
|
+
|
|
745
|
+
clock.now += 2
|
|
746
|
+
acknowledged(number)
|
|
747
|
+
}
|
|
748
|
+
)
|
|
749
|
+
api = api_server(store)
|
|
750
|
+
control = Files::Transfer::Control.new
|
|
751
|
+
reports = []
|
|
752
|
+
failure = nil
|
|
753
|
+
|
|
754
|
+
with_local_file(data.byteslice(0, 12)) do |path|
|
|
755
|
+
# Part 2's retry delay passes only as part 3's acknowledgement moves the clock, so an upload that held part 3 back
|
|
756
|
+
# behind it would stall: it gets ten seconds on a thread of its own, which keeps what the upload raised.
|
|
757
|
+
uploading = Thread.new do
|
|
758
|
+
upload(path, progress: ->(report) { reports << report }, transfer: { concurrency: 1, control: control })
|
|
759
|
+
rescue StandardError => e
|
|
760
|
+
failure = e
|
|
761
|
+
end
|
|
762
|
+
expect(uploading.join(10)).to be_truthy
|
|
763
|
+
ensure
|
|
764
|
+
# However the wait ended, the upload is cancelled, which one that finished ignores, and its caller joined before
|
|
765
|
+
# the file, the clock's stub and the servers go. None of it needs the clock to move: the cancellation wakes the
|
|
766
|
+
# upload at once, and its own job, with nothing left running, closes without waiting out its deadline. A wait
|
|
767
|
+
# that failed stays the failure reported, not the cancellation the caller then met.
|
|
768
|
+
control.cancel
|
|
769
|
+
uploading&.join
|
|
770
|
+
end
|
|
771
|
+
raise failure if failure
|
|
772
|
+
|
|
773
|
+
# A part waiting out its retry delay does not hold back the parts after it.
|
|
774
|
+
expect(parts_sent(store).map { |request| [ request.target, request.body ] }.sort).to eq([ %w[/parts/1 abcd], %w[/parts/2 efgh], %w[/parts/2 efgh], %w[/parts/3 ijkl] ])
|
|
775
|
+
expect(reports.map { |report| report[:part_number] }.sort).to eq([ 1, 2, 3 ])
|
|
776
|
+
expect(reports.map { |report| report[:acknowledged_bytes] }).to eq([ 4, 8, 12 ])
|
|
777
|
+
# Each report also gives the file's and the part's sizes, the seconds since the call began and the bytes storage
|
|
778
|
+
# acknowledged over them: a part's 4 every 2 seconds, the retried part's once.
|
|
779
|
+
expect(reports.map { |report| report.values_at(:total_bytes, :part_bytes, :elapsed_seconds, :bytes_per_second) })
|
|
780
|
+
.to eq([ [ 12, 4, 2.0, 2.0 ], [ 12, 4, 4.0, 2.0 ], [ 12, 4, 6.0, 2.0 ] ])
|
|
781
|
+
expect(ends(api).first["etags"].map { |etag| etag["part"] }).to eq(%w[1 2 3])
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
[
|
|
785
|
+
[ "the instructions say retry_parts: false", ->(_number, _params) { { retry_parts: false } }, 500 ],
|
|
786
|
+
[ "storage answers 501", ->(_number, _params) { {} }, 501 ]
|
|
787
|
+
].each do |reason, grant, status|
|
|
788
|
+
it "does not retry a part when #{reason}" do
|
|
789
|
+
Files.max_network_retries = 2
|
|
790
|
+
store = storage_server(part: ->(_number, _request) { TransferServer.response("#{status} Failed", { "Content-Length" => 0 }) })
|
|
791
|
+
api = api_server(store, grant: grant)
|
|
792
|
+
|
|
793
|
+
with_local_file(data) do |path|
|
|
794
|
+
expect { upload(path, transfer: { concurrency: 1 }) }
|
|
795
|
+
.to raise_error(Files::APIError, "Transfer request failed") { |error| expect(error.http_status).to eq(status) }
|
|
796
|
+
end
|
|
797
|
+
expect(parts_sent(store).size).to eq(1)
|
|
798
|
+
expect(ends(api)).to be_empty
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
it "renews an expired signed URL before sending, keeping the reference, part and size" do
|
|
803
|
+
store = storage_server
|
|
804
|
+
expired = ->(number, params) { params["parts"] == "5" ? { upload_uri: store.url("/parts/#{number}?X-Amz-Date=20000101T000000Z&X-Amz-Expires=60") } : {} }
|
|
805
|
+
api = api_server(store, grant: expired)
|
|
806
|
+
|
|
807
|
+
with_local_file(data.byteslice(0, 12)) { |path| upload(path, transfer: { concurrency: 1 }) }
|
|
808
|
+
|
|
809
|
+
expect(begins(api).drop(1).map { |params| params.values_at("part", "parts", "ref") }).to eq([ %w[1 1 upload-ref], %w[2 1 upload-ref], %w[3 1 upload-ref] ])
|
|
810
|
+
expect(parts_sent(store).map(&:target)).to eq(%w[/parts/1 /parts/2 /parts/3])
|
|
811
|
+
expect(ends(api).size).to eq(1)
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
it "refuses a renewal that changes the part's size, sending nothing for it" do
|
|
815
|
+
store = storage_server
|
|
816
|
+
renewal = lambda do |number, params|
|
|
817
|
+
params["parts"] == "5" ? { upload_uri: store.url("/parts/#{number}?X-Amz-Date=20000101T000000Z&X-Amz-Expires=60") } : { partsize: 5 }
|
|
818
|
+
end
|
|
819
|
+
api = api_server(store, grant: renewal)
|
|
820
|
+
|
|
821
|
+
with_local_file(data.byteslice(0, 12)) do |path|
|
|
822
|
+
expect { upload(path, transfer: { concurrency: 1 }) }.to raise_error(Files::APIError, "The renewed upload instructions changed")
|
|
823
|
+
end
|
|
824
|
+
expect(parts_sent(store)).to be_empty
|
|
825
|
+
expect(ends(api)).to be_empty
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
it "stops without sending a part whose renewals still give an expired URL" do
|
|
829
|
+
store = storage_server
|
|
830
|
+
api = api_server(store, grant: ->(number, _params) { { upload_uri: store.url("/parts/#{number}?X-Amz-Date=20000101T000000Z&X-Amz-Expires=60") } })
|
|
831
|
+
|
|
832
|
+
with_local_file(data.byteslice(0, 4)) do |path|
|
|
833
|
+
expect { upload(path, transfer: { concurrency: 1 }) }.to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(403) }
|
|
834
|
+
end
|
|
835
|
+
expect(parts_sent(store)).to be_empty
|
|
836
|
+
expect(begins(api).size).to eq(3)
|
|
837
|
+
expect(ends(api)).to be_empty
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
it "does not send a retry_parts: false part again when its URL expired while it was in flight" do
|
|
841
|
+
Files.max_network_retries = 2
|
|
842
|
+
store = storage_server(part: lambda { |_number, _request|
|
|
843
|
+
sleep 1.2
|
|
844
|
+
TransferServer.response("403 Forbidden", { "Content-Length" => 0 })
|
|
845
|
+
}
|
|
846
|
+
)
|
|
847
|
+
api = api_server(store, grant: ->(_number, _params) { { retry_parts: false, expires: (Time.now + 6).utc.iso8601 } })
|
|
848
|
+
|
|
849
|
+
with_local_file(data.byteslice(0, 4)) do |path|
|
|
850
|
+
expect { upload(path, transfer: { concurrency: 1 }) }.to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(403) }
|
|
851
|
+
end
|
|
852
|
+
expect(parts_sent(store).size).to eq(1)
|
|
853
|
+
expect(begins(api).size).to eq(1)
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
it "waits out a Retry-After longer than a minute, which a cancellation still ends at once" do
|
|
857
|
+
Files.max_network_retries = 1
|
|
858
|
+
store = storage_server(part: ->(_number, _request) { TransferServer.response("429 Too Many Requests", { "Retry-After" => "120", "Content-Length" => 0 }) })
|
|
859
|
+
api = api_server(store)
|
|
860
|
+
control = Files::Transfer::Control.new
|
|
861
|
+
canceller = Thread.new do
|
|
862
|
+
store.wait_for_requests(1)
|
|
863
|
+
sleep 0.5
|
|
864
|
+
control.cancel
|
|
865
|
+
end
|
|
866
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
867
|
+
|
|
868
|
+
with_local_file(data.byteslice(0, 4)) do |path|
|
|
869
|
+
expect { upload(path, transfer: { concurrency: 1, control: control }) }.to raise_error(Files::Transfer::CancelledError)
|
|
870
|
+
end
|
|
871
|
+
canceller.join
|
|
872
|
+
|
|
873
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 5
|
|
874
|
+
expect(parts_sent(store).size).to eq(1)
|
|
875
|
+
expect(ends(api)).to be_empty
|
|
876
|
+
retry_after = Files::Transfer::StorageRetry
|
|
877
|
+
expect(retry_after.classify(429, { "retry-after" => [ "120" ] }, "")).to eq([ :throttle, 120.0 ])
|
|
878
|
+
now = Time.now
|
|
879
|
+
expect(retry_after.retry_after((now + 300).httpdate, now: now)).to be_within(1).of(300)
|
|
880
|
+
expect(retry_after.classify(503, { "retry-after" => [ "9" * 400 ] }, "")).to eq([ :refuse ])
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
it "holds every new part and retry back until a throttle's Retry-After has passed" do
|
|
884
|
+
Files.max_network_retries = 1
|
|
885
|
+
lock = Mutex.new
|
|
886
|
+
arrivals = []
|
|
887
|
+
store = storage_server(part: lambda { |number, _request|
|
|
888
|
+
first = lock.synchronize do
|
|
889
|
+
arrivals << Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
890
|
+
arrivals.size == 1
|
|
891
|
+
end
|
|
892
|
+
first ? TransferServer.response("429 Too Many Requests", { "Retry-After" => "1", "Content-Length" => 0 }) : acknowledged(number)
|
|
893
|
+
}
|
|
894
|
+
)
|
|
895
|
+
api = api_server(store)
|
|
896
|
+
|
|
897
|
+
with_local_file(data.byteslice(0, 12)) { |path| upload(path, transfer: { concurrency: 1 }) }
|
|
898
|
+
|
|
899
|
+
# The throttled first part, then, a second later at the earliest, its retry and the two parts after it.
|
|
900
|
+
expect(arrivals.size).to eq(4)
|
|
901
|
+
expect(arrivals.drop(1).map { |at| at - arrivals.first }).to all(be >= 0.9)
|
|
902
|
+
expect(ends(api).size).to eq(1)
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
[
|
|
906
|
+
[ %(<?xml version="1.0" encoding="UTF-8"?>\n<Error><Code>RequestTimeout</Code><Message>Idle</Message></Error>\n), 2 ],
|
|
907
|
+
[ "<Error><Code>AccessDenied</Code><Message><Code>RequestTimeout</Code></Message></Error>", 1 ],
|
|
908
|
+
[ "<Error><Code>RequestTimeout</Code><Broken></Error>", 1 ],
|
|
909
|
+
[ "<!DOCTYPE Error><Error><Code>RequestTimeout</Code></Error>", 1 ],
|
|
910
|
+
[ %(<Error xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Code>RequestTimeout</Code></Error>), 1 ],
|
|
911
|
+
[ "<Error><Code>RequestTimeout</Code><Code>RequestTimeout</Code></Error>", 1 ],
|
|
912
|
+
[ %(<Error><Code xmlns="urn:not-s3">RequestTimeout</Code></Error>), 1 ],
|
|
913
|
+
[ %(<Error x="1" x="2"><Code>RequestTimeout</Code></Error>), 1 ],
|
|
914
|
+
# A valid RequestTimeout padded to 64 KiB, then one byte more that the transport keeps no part of.
|
|
915
|
+
[ "#{'<Error><Code>RequestTimeout</Code></Error>'.ljust(65_536)}x", 1 ]
|
|
916
|
+
].each do |body, sends|
|
|
917
|
+
it "#{sends == 2 ? 'retries' : 'does not retry'} a 400 whose body is #{body.lines.last.strip[0, 40].inspect}" do
|
|
918
|
+
Files.max_network_retries = 1
|
|
919
|
+
lock = Mutex.new
|
|
920
|
+
answered = 0
|
|
921
|
+
store = storage_server(part: lambda { |number, _request|
|
|
922
|
+
first = lock.synchronize { (answered += 1) == 1 }
|
|
923
|
+
first ? TransferServer.response("400 Bad Request", { "Content-Length" => body.bytesize }, body) : acknowledged(number)
|
|
924
|
+
}
|
|
925
|
+
)
|
|
926
|
+
api = api_server(store)
|
|
927
|
+
|
|
928
|
+
with_local_file(data.byteslice(0, 4)) do |path|
|
|
929
|
+
if sends == 2
|
|
930
|
+
upload(path, transfer: { concurrency: 1 })
|
|
931
|
+
else
|
|
932
|
+
expect { upload(path, transfer: { concurrency: 1 }) }.to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(400) }
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
expect(parts_sent(store).size).to eq(sends)
|
|
936
|
+
expect(ends(api).size).to eq(sends - 1)
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
describe "stopping" do
|
|
942
|
+
it "raises the progress callback's own error, with no part admitted and nothing ended after it" do
|
|
943
|
+
store = storage_server
|
|
944
|
+
api = api_server(store)
|
|
945
|
+
stopped = StandardError.new("stopped by the caller")
|
|
946
|
+
|
|
947
|
+
with_local_file(data) do |path|
|
|
948
|
+
expect { upload(path, progress: ->(_report) { raise stopped }, transfer: { concurrency: 1 }) }
|
|
949
|
+
.to raise_error(StandardError) { |error| expect(error).to equal(stopped) }
|
|
950
|
+
end
|
|
951
|
+
expect(parts_sent(store).size).to eq(1)
|
|
952
|
+
expect(ends(api)).to be_empty
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
it "admits nothing after a cancel and releases what it held" do
|
|
956
|
+
store = storage_server
|
|
957
|
+
api = api_server(store)
|
|
958
|
+
control = Files::Transfer::Control.new
|
|
959
|
+
|
|
960
|
+
Files::Transfer::Job.open(concurrency: 1, control: control) do |job|
|
|
961
|
+
with_local_file(data) do |path|
|
|
962
|
+
expect { upload(path, progress: ->(_report) { control.cancel }, transfer: job) }.to raise_error(Files::Transfer::CancelledError)
|
|
963
|
+
end
|
|
964
|
+
expect(job.held_bytes).to eq(0)
|
|
965
|
+
end
|
|
966
|
+
expect(parts_sent(store).size).to eq(1)
|
|
967
|
+
expect(ends(api)).to be_empty
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
it "stops before the next part when the local file changes, without ending the upload" do
|
|
971
|
+
changed = nil
|
|
972
|
+
store = storage_server(part: lambda { |number, _request|
|
|
973
|
+
changed&.call
|
|
974
|
+
acknowledged(number)
|
|
975
|
+
}
|
|
976
|
+
)
|
|
977
|
+
api = api_server(store)
|
|
978
|
+
|
|
979
|
+
with_local_file(data) do |path|
|
|
980
|
+
changed = -> { File.binwrite(path, "ab") }
|
|
981
|
+
expect { upload(path, transfer: { concurrency: 1 }) }.to raise_error(Files::Transfer::SourceChangedError)
|
|
982
|
+
end
|
|
983
|
+
expect(parts_sent(store).size).to eq(1)
|
|
984
|
+
expect(ends(api)).to be_empty
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
it "pauses with a checkpoint that resumes only the parts not yet acknowledged" do
|
|
988
|
+
clock = controlled_clock
|
|
989
|
+
seconds_per_part = 0
|
|
990
|
+
# Storage takes seconds_per_part of the example's clock to acknowledge a part: none before the pause, two after it.
|
|
991
|
+
store = storage_server(part: lambda { |number, _request|
|
|
992
|
+
clock.now += seconds_per_part
|
|
993
|
+
acknowledged(number)
|
|
994
|
+
}
|
|
995
|
+
)
|
|
996
|
+
api = api_server(store)
|
|
997
|
+
control = Files::Transfer::Control.new
|
|
998
|
+
paused_reports = []
|
|
999
|
+
reports = []
|
|
1000
|
+
|
|
1001
|
+
with_local_file(data.byteslice(0, 12)) do |path|
|
|
1002
|
+
paused = nil
|
|
1003
|
+
pause = lambda do |report|
|
|
1004
|
+
paused_reports << report
|
|
1005
|
+
control.pause
|
|
1006
|
+
end
|
|
1007
|
+
expect { upload(path, progress: pause, transfer: { concurrency: 1, control: control }) }
|
|
1008
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1009
|
+
checkpoint = paused.checkpoint
|
|
1010
|
+
expect([ checkpoint.direction, checkpoint.completed_bytes, checkpoint.total_bytes ]).to eq([ :upload, 4, 12 ])
|
|
1011
|
+
expect(checkpoint.inspect).not_to include("upload-ref", "/parts/", "fixture")
|
|
1012
|
+
expect { Marshal.dump(checkpoint) }.to raise_error(TypeError)
|
|
1013
|
+
expect(ends(api)).to be_empty
|
|
1014
|
+
|
|
1015
|
+
# The upload resumes an hour after its pause.
|
|
1016
|
+
clock.now += 3600
|
|
1017
|
+
seconds_per_part = 2
|
|
1018
|
+
described_class.resume_upload(checkpoint, {}, progress: ->(report) { reports << report }, transfer: { concurrency: 1 })
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
expect(parts_sent(store).map(&:target)).to eq(%w[/parts/1 /parts/2 /parts/3])
|
|
1022
|
+
expect(reports.map { |report| report.values_at(:part_number, :acknowledged_bytes) }).to eq([ [ 2, 8 ], [ 3, 12 ] ])
|
|
1023
|
+
# No time had passed when the paused call's part was acknowledged, so its rate was zero. The resume's seconds start
|
|
1024
|
+
# with the resume, not the paused call, and its rate counts only the 8 bytes it acknowledged, 4 every 2 seconds,
|
|
1025
|
+
# though its acknowledged_bytes include the checkpoint's 4.
|
|
1026
|
+
expect(paused_reports.map { |report| report.values_at(:acknowledged_bytes, :elapsed_seconds, :bytes_per_second) }).to eq([ [ 4, 0.0, 0.0 ] ])
|
|
1027
|
+
expect(reports.map { |report| report.values_at(:elapsed_seconds, :bytes_per_second) }).to eq([ [ 2.0, 2.0 ], [ 4.0, 2.0 ] ])
|
|
1028
|
+
expect(begins(api).drop(1).map { |params| params.values_at("part", "parts", "ref") }).to eq([ %w[2 1 upload-ref], %w[3 1 upload-ref] ])
|
|
1029
|
+
expect(ends(api).first["etags"].map { |etag| etag["part"] }).to eq(%w[1 2 3])
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
it "refuses to resume from a checkpoint whose local file changed" do
|
|
1033
|
+
store = storage_server
|
|
1034
|
+
api_server(store)
|
|
1035
|
+
control = Files::Transfer::Control.new
|
|
1036
|
+
|
|
1037
|
+
with_local_file(data.byteslice(0, 12)) do |path|
|
|
1038
|
+
paused = nil
|
|
1039
|
+
expect { upload(path, progress: ->(_report) { control.pause }, transfer: { concurrency: 1, control: control }) }
|
|
1040
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1041
|
+
File.binwrite(path, "a different file")
|
|
1042
|
+
|
|
1043
|
+
expect { described_class.resume_upload(paused.checkpoint, {}) }.to raise_error(Files::Transfer::CheckpointError)
|
|
1044
|
+
end
|
|
1045
|
+
expect(parts_sent(store).size).to eq(1)
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
it "raises a failure that came before a cancellation, not the cancellation" do
|
|
1049
|
+
store = storage_server(part: lambda { |number, _request|
|
|
1050
|
+
number == 2 ? TransferServer.response("501 Not Implemented", { "Content-Length" => 0 }) : acknowledged(number)
|
|
1051
|
+
}
|
|
1052
|
+
)
|
|
1053
|
+
api = api_server(store)
|
|
1054
|
+
control = Files::Transfer::Control.new
|
|
1055
|
+
# The first report holds the caller's thread until part 2's refusal is back, then cancels.
|
|
1056
|
+
cancel_late = lambda do |_report|
|
|
1057
|
+
store.wait_for_requests(2)
|
|
1058
|
+
sleep 0.5
|
|
1059
|
+
control.cancel
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
with_local_file(data.byteslice(0, 8)) do |path|
|
|
1063
|
+
expect { upload(path, progress: cancel_late, transfer: { concurrency: 2, control: control }) }
|
|
1064
|
+
.to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(501) }
|
|
1065
|
+
end
|
|
1066
|
+
expect(ends(api)).to be_empty
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
it "raises a part's failure that came back before the caller's own callback failed" do
|
|
1070
|
+
store = storage_server(part: lambda { |number, _request|
|
|
1071
|
+
number == 2 ? TransferServer.response("501 Not Implemented", { "Content-Length" => 0 }) : acknowledged(number)
|
|
1072
|
+
}
|
|
1073
|
+
)
|
|
1074
|
+
api = api_server(store)
|
|
1075
|
+
callback_failure = StandardError.new("the caller's callback failed later")
|
|
1076
|
+
# The first report holds the caller's thread until part 2's refusal is back, then fails itself.
|
|
1077
|
+
fail_late = lambda do |_report|
|
|
1078
|
+
store.wait_for_requests(2)
|
|
1079
|
+
sleep 0.5
|
|
1080
|
+
raise callback_failure
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
with_local_file(data.byteslice(0, 8)) do |path|
|
|
1084
|
+
expect { upload(path, progress: fail_late, transfer: { concurrency: 2 }) }
|
|
1085
|
+
.to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(501) }
|
|
1086
|
+
end
|
|
1087
|
+
expect(ends(api)).to be_empty
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
it "opens nothing before it has a file slot and is still running, and gives no checkpoint for a pause while its file opens" do
|
|
1091
|
+
store = storage_server
|
|
1092
|
+
api = api_server(store)
|
|
1093
|
+
control = Files::Transfer::Control.new
|
|
1094
|
+
control.pause
|
|
1095
|
+
|
|
1096
|
+
expect { upload("/nonexistent/report.bin", transfer: { control: control }) }
|
|
1097
|
+
.to raise_error(Files::Transfer::PausedError) { |error| expect(error.checkpoint).to be_nil }
|
|
1098
|
+
expect(api.requests).to be_empty
|
|
1099
|
+
|
|
1100
|
+
# Opening the file pauses the upload and then fails: the pause came first, with nothing opened to resume.
|
|
1101
|
+
opening = Files::Transfer::Control.new
|
|
1102
|
+
path = Object.new
|
|
1103
|
+
path.define_singleton_method(:to_path) do
|
|
1104
|
+
opening.pause
|
|
1105
|
+
"/nonexistent/report.bin"
|
|
1106
|
+
end
|
|
1107
|
+
job = Files::Transfer::Job.new(control: opening)
|
|
1108
|
+
expect { described_class.upload_file(path, "report.bin", {}, transfer: job) }
|
|
1109
|
+
.to raise_error(Files::Transfer::PausedError) { |error| expect(error.checkpoint).to be_nil }
|
|
1110
|
+
expect(job.diagnostics.values_at(:requests, :held_bytes)).to eq([ 0, 0 ])
|
|
1111
|
+
expect(job.close).to be(true)
|
|
1112
|
+
expect(api.requests).to be_empty
|
|
1113
|
+
expect(store.requests).to be_empty
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
it "lets one resume hold a checkpoint at a time, and none use it once one has started" do
|
|
1117
|
+
control = Files::Transfer::Control.new
|
|
1118
|
+
holding = Queue.new
|
|
1119
|
+
held = false
|
|
1120
|
+
store = storage_server(part: lambda { |number, _request|
|
|
1121
|
+
control.pause if number == 1
|
|
1122
|
+
holding.pop if number == 2 && held
|
|
1123
|
+
acknowledged(number)
|
|
1124
|
+
}
|
|
1125
|
+
)
|
|
1126
|
+
api = api_server(store)
|
|
1127
|
+
|
|
1128
|
+
with_local_file(data.byteslice(0, 12)) do |path|
|
|
1129
|
+
paused = nil
|
|
1130
|
+
expect { upload(path, transfer: { concurrency: 1, control: control }) }.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1131
|
+
checkpoint = paused.checkpoint
|
|
1132
|
+
expect(checkpoint).not_to respond_to(:claim!, :release!)
|
|
1133
|
+
|
|
1134
|
+
held = true
|
|
1135
|
+
first = Thread.new { described_class.resume_upload(checkpoint, {}, transfer: { concurrency: 1 }) }
|
|
1136
|
+
store.wait_for_requests(2)
|
|
1137
|
+
expect { described_class.resume_upload(checkpoint, {}) }.to raise_error(Files::Transfer::CheckpointError, /another resume/)
|
|
1138
|
+
holding << :go
|
|
1139
|
+
first.join(10)
|
|
1140
|
+
expect { described_class.resume_upload(checkpoint, {}) }.to raise_error(Files::Transfer::CheckpointError, /already been used/)
|
|
1141
|
+
end
|
|
1142
|
+
expect(parts_sent(store).map(&:target)).to eq(%w[/parts/1 /parts/2 /parts/3])
|
|
1143
|
+
expect(ends(api).size).to eq(1)
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
it "keeps only safe upload state in a checkpoint, and refuses a resumed part whose instructions changed" do
|
|
1147
|
+
control = Files::Transfer::Control.new
|
|
1148
|
+
store = storage_server(part: lambda { |number, _request|
|
|
1149
|
+
control.pause if number == 1
|
|
1150
|
+
acknowledged(number)
|
|
1151
|
+
}
|
|
1152
|
+
)
|
|
1153
|
+
headers = { "x-amz-checksum-crc32" => "first" }
|
|
1154
|
+
api = api_server(store, grant: ->(_number, _params) { { headers: headers } })
|
|
1155
|
+
record = described_class.new({ path: "report.bin", size: 999, download_uri: "https://storage.example.test/report.bin?X-Amz-Signature=secret",
|
|
1156
|
+
mkdir_parents: true, nested: { deep: "response field" } }, { session_id: "record-session" }
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
with_local_file(data.byteslice(0, 12)) do |path|
|
|
1160
|
+
paused = nil
|
|
1161
|
+
File.open(path, "rb") do |local|
|
|
1162
|
+
expect { record.upload_file(local, transfer: { concurrency: 1, control: control }) }.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1163
|
+
end
|
|
1164
|
+
checkpoint = paused.checkpoint
|
|
1165
|
+
everything = [ checkpoint.state, checkpoint.source, checkpoint.sink, checkpoint.inspect ].inspect
|
|
1166
|
+
expect(everything).not_to include("secret", "Signature", "response field", "record-session", "/parts/", "first")
|
|
1167
|
+
expect(checkpoint.state[:params]).to eq({ mkdir_parents: true, size: 12 })
|
|
1168
|
+
expect(checkpoint.state).to be_frozen
|
|
1169
|
+
expect(checkpoint.state[:layout]).to all(be_frozen)
|
|
1170
|
+
|
|
1171
|
+
headers = { "x-amz-checksum-crc32" => "changed" }
|
|
1172
|
+
expect { described_class.resume_upload(checkpoint, { session_id: "record-session" }, transfer: { concurrency: 1 }) }
|
|
1173
|
+
.to raise_error(Files::APIError, "The renewed upload instructions changed")
|
|
1174
|
+
# That resume started, so its failure spent the checkpoint.
|
|
1175
|
+
headers = { "x-amz-checksum-crc32" => "first" }
|
|
1176
|
+
expect { described_class.resume_upload(checkpoint, { session_id: "record-session" }) }.to raise_error(Files::Transfer::CheckpointError, /already been used/)
|
|
1177
|
+
end
|
|
1178
|
+
expect(parts_sent(store).size).to eq(1)
|
|
1179
|
+
expect(ends(api)).to be_empty
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
it "does not report a job closed while a transfer's request is still in flight" do
|
|
1183
|
+
gate = Queue.new
|
|
1184
|
+
store = storage_server(part: lambda { |number, _request|
|
|
1185
|
+
gate.pop
|
|
1186
|
+
acknowledged(number)
|
|
1187
|
+
}
|
|
1188
|
+
)
|
|
1189
|
+
api_server(store)
|
|
1190
|
+
job = Files::Transfer::Job.new(concurrency: 1)
|
|
1191
|
+
failure = nil
|
|
1192
|
+
uploader = nil
|
|
1193
|
+
|
|
1194
|
+
with_local_file(data.byteslice(0, 4)) do |path|
|
|
1195
|
+
uploader = Thread.new do
|
|
1196
|
+
upload(path, transfer: job)
|
|
1197
|
+
rescue StandardError => e
|
|
1198
|
+
failure = e
|
|
1199
|
+
end
|
|
1200
|
+
store.wait_for_requests(1)
|
|
1201
|
+
|
|
1202
|
+
expect { job.close }.to raise_error(Files::Error, /still running/)
|
|
1203
|
+
gate << :go
|
|
1204
|
+
uploader.join(10)
|
|
1205
|
+
end
|
|
1206
|
+
expect(failure).to be_a(Files::Error)
|
|
1207
|
+
expect(job.close).to be(true)
|
|
1208
|
+
end
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
describe "limits" do
|
|
1212
|
+
it "refuses a part larger than buffer_bytes before reading or sending it" do
|
|
1213
|
+
requests = []
|
|
1214
|
+
client = stubbed_api_client do |stub|
|
|
1215
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/report.bin") do |_env|
|
|
1216
|
+
requests << :begin
|
|
1217
|
+
json_response(200, [ { part_number: 1, partsize: 100_000, ref: "upload-ref", http_method: "PUT", upload_uri: "https://storage.example.test/parts/1" } ])
|
|
1218
|
+
end
|
|
1219
|
+
stub.put(/\A\/parts\/\d+\z/) { |_env| (requests << :part) && [ 200, { "ETag" => "\"etag\"" }, "" ] }
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
with_local_file("x" * 150_000) do |path|
|
|
1223
|
+
expect { upload(path, { client: client }, transfer: { buffer_bytes: 65_536 }) }.to raise_error(Files::Transfer::BufferLimitError)
|
|
1224
|
+
end
|
|
1225
|
+
expect(requests).to eq([ :begin ])
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
it "refuses invalid transfer options before sending anything" do
|
|
1229
|
+
store = storage_server
|
|
1230
|
+
api = api_server(store)
|
|
1231
|
+
invalid = [ { concurrency: 0 }, { concurrency: 257 }, { concurrency: 2, startup: :aggressive }, { max_concurrency: 0 }, { concurrency: 2, max_concurrency: 4 },
|
|
1232
|
+
{ buffer_bytes: 1024 }, { max_files: 0 }, { control: Object.new }, { unknown: true }, "fast" ]
|
|
1233
|
+
|
|
1234
|
+
with_local_file(data) do |path|
|
|
1235
|
+
invalid.each { |options| expect { upload(path, transfer: options) }.to raise_error(Files::InvalidParameterError) }
|
|
1236
|
+
end
|
|
1237
|
+
expect(api.requests).to be_empty
|
|
1238
|
+
end
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
# Parts an upload sizes itself, for an ordinary multipart upload straight to Amazon S3. A caller's client carries them
|
|
1242
|
+
# here, so the API and S3 answer through Faraday's test adapter: the client's own remote_request keeps each part's URL,
|
|
1243
|
+
# headers and body exactly as the transfer hands them over, and the adapter keeps each part as it reaches it.
|
|
1244
|
+
describe "part sizes for Amazon S3" do
|
|
1245
|
+
let(:mib) { 1024 * 1024 }
|
|
1246
|
+
# 17 MiB and 3 bytes, no two parts alike.
|
|
1247
|
+
let(:content) { Random.new(17).bytes((17 * mib) + 3) }
|
|
1248
|
+
let(:signed_at) { Time.now.utc.strftime("%Y%m%dT%H%M%SZ") }
|
|
1249
|
+
let(:sent) { [] }
|
|
1250
|
+
let(:delivered) { [] }
|
|
1251
|
+
let(:begun) { [] }
|
|
1252
|
+
let(:api_targets) { [] }
|
|
1253
|
+
let(:ended) { [] }
|
|
1254
|
+
|
|
1255
|
+
# A presigned UploadPart URL as S3 gives one, for upload_id at host (and, for a path-style URL, the bucket after
|
|
1256
|
+
# it), signing signed, with what tail adds before its signature. expired dates it long ago.
|
|
1257
|
+
def s3_url(number, host: "reports.s3.us-east-1.amazonaws.com", upload_id: "s3-upload-1", signed: "host", tail: "", expired: false)
|
|
1258
|
+
"https://#{host}/uploads/report.bin?partNumber=#{number}&uploadId=#{upload_id}&X-Amz-Algorithm=AWS4-HMAC-SHA256" \
|
|
1259
|
+
"&X-Amz-Credential=AKIDFIXTURE%2F#{signed_at[0, 8]}%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=#{expired ? '20000101T000000Z' : signed_at}" \
|
|
1260
|
+
"&X-Amz-Expires=#{expired ? 60 : 3600}&X-Amz-SignedHeaders=#{signed}#{tail}&X-Amz-Signature=#{'5d' * 32}"
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
# The API's answer for part number of an upload to S3, recommending hint bytes for each part, with changes.
|
|
1264
|
+
def s3_grant(number, hint: 5 * mib, **changes)
|
|
1265
|
+
{ part_number: number, partsize: hint, next_partsize: hint, available_parts: 10_000, ref: "upload-ref", http_method: "PUT", action: "put/write",
|
|
1266
|
+
parallel_parts: true, retry_parts: true, headers: {}, parameters: {}, send: { partsize: "required-header Content-Length", partdata: "body" },
|
|
1267
|
+
upload_uri: s3_url(number) }.merge(changes)
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
# A caller's client playing the API and S3 for report.bin. begin_upload gives grant's answer for each part asked for;
|
|
1271
|
+
# S3 answers a part 500 when refuse says so for that attempt, and otherwise acknowledges it with an ETag naming it.
|
|
1272
|
+
# begun keeps each begin_upload's part and parts, api_targets the URL each API request reached the adapter at,
|
|
1273
|
+
# ended each end's params, sent each part as the transfer hands it to the client's remote_request (its URL, headers
|
|
1274
|
+
# and body), and delivered each part as it reached the adapter (its URL, Content-Length and body).
|
|
1275
|
+
def s3_client(grant: ->(number, _params) { s3_grant(number) }, refuse: ->(_number, _attempt) { false })
|
|
1276
|
+
begun_log = begun
|
|
1277
|
+
api_log = api_targets
|
|
1278
|
+
ended_log = ended
|
|
1279
|
+
sent_log = sent
|
|
1280
|
+
delivered_log = delivered
|
|
1281
|
+
attempts = Hash.new(0)
|
|
1282
|
+
client = stubbed_api_client do |stub|
|
|
1283
|
+
stub.post("/api/rest/v1/file_actions/begin_upload/report.bin") do |env|
|
|
1284
|
+
params = Faraday::Utils.parse_nested_query(env.body)
|
|
1285
|
+
begun_log << params.values_at("part", "parts")
|
|
1286
|
+
api_log << env.url.to_s
|
|
1287
|
+
first = Integer(params.fetch("part"))
|
|
1288
|
+
json_response(200, (first...(first + Integer(params.fetch("parts")))).map { |number| grant.call(number, params) })
|
|
1289
|
+
end
|
|
1290
|
+
stub.put(/\/uploads\/report\.bin\z/) do |env|
|
|
1291
|
+
delivered_log << [ env.url.to_s, env.request_headers["Content-Length"], env.body ]
|
|
1292
|
+
number = URI.decode_www_form(env.url.query).to_h.fetch("partNumber")
|
|
1293
|
+
refuse.call(number, attempts[number] += 1) ? [ 500, {}, "" ] : [ 200, { "ETag" => "\"etag-#{number}\"" }, "" ]
|
|
1294
|
+
end
|
|
1295
|
+
stub.post("/api/rest/v1/files/report.bin") do |env|
|
|
1296
|
+
api_log << env.url.to_s
|
|
1297
|
+
ended_log << Faraday::Utils.parse_nested_query(env.body)
|
|
1298
|
+
json_response(201, { path: "report.bin", type: "file" })
|
|
1299
|
+
end
|
|
1300
|
+
end
|
|
1301
|
+
client.define_singleton_method(:remote_request) do |verb, url, headers = {}, body = nil, &block|
|
|
1302
|
+
sent_log << [ url, headers.dup, body ]
|
|
1303
|
+
super(verb, url, headers, body, &block)
|
|
1304
|
+
end
|
|
1305
|
+
client
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
# Each part sent: its URL, its Content-Length and a digest of its body.
|
|
1309
|
+
def parts_sent_to_s3
|
|
1310
|
+
sent.map { |url, headers, body| [ url, headers["Content-Length"], Digest::SHA256.hexdigest(body) ] }
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
# Each part as it reached the adapter: its URL, its Content-Length and a digest of its body.
|
|
1314
|
+
def parts_delivered_to_s3
|
|
1315
|
+
delivered.map { |url, length, body| [ url, length, Digest::SHA256.hexdigest(body) ] }
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
# bytes as a part sent to url should be.
|
|
1319
|
+
def part_of(url, bytes)
|
|
1320
|
+
[ url, bytes.bytesize.to_s, Digest::SHA256.hexdigest(bytes) ]
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
def numbers_sent
|
|
1324
|
+
sent.map { |url, _, _| URI.decode_www_form(URI.parse(url).query).to_h["partNumber"] }
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1327
|
+
def upload_with(client, bytes, **keywords)
|
|
1328
|
+
with_local_file(bytes) { |path| upload(path, { client: client }, **keywords) }
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
it "sizes the parts itself, sending each one's exact bytes and Content-Length to the URL the API gave, as given" do
|
|
1332
|
+
reports = []
|
|
1333
|
+
# The URLs the API gives, which a signature covers as they are, name partNumber and uploadId before the SigV4
|
|
1334
|
+
# parameters, and escape their session token's plus sign and equals sign in lowercase and its tilde too, where
|
|
1335
|
+
# Faraday's own encoding would sort the parameters and escape them its own way. The client's connection has a
|
|
1336
|
+
# parameter of its own, which its API requests carry and the parts must not.
|
|
1337
|
+
signed = ->(number) { s3_url(number, tail: "&X-Amz-Security-Token=Fixture%2btoken%7E%3d") }
|
|
1338
|
+
client = s3_client(grant: ->(number, _params) { s3_grant(number, upload_uri: signed.call(number)) })
|
|
1339
|
+
client.conn.params["fixture"] = "connection"
|
|
1340
|
+
|
|
1341
|
+
upload_with(client, content, progress: ->(report) { reports << report }, transfer: true)
|
|
1342
|
+
|
|
1343
|
+
# The API recommended 5 MiB parts; the upload sent 8 MiB ones, and the rest, each reaching the adapter at the URL
|
|
1344
|
+
# the API gave, character for character, with exactly its bytes and their Content-Length.
|
|
1345
|
+
expected = [ part_of(signed.call(1), content.byteslice(0, 8 * mib)), part_of(signed.call(2), content.byteslice(8 * mib, 8 * mib)),
|
|
1346
|
+
part_of(signed.call(3), content.byteslice(16 * mib, 8 * mib)) ]
|
|
1347
|
+
expect(parts_sent_to_s3.sort).to eq(expected)
|
|
1348
|
+
expect(parts_delivered_to_s3.sort).to eq(expected)
|
|
1349
|
+
expect(sent.map { |_, headers, _| headers.keys.sort }.uniq).to eq([ %w[Content-Length Content-Type] ])
|
|
1350
|
+
expect(api_targets.map { |url| URI.parse(url).query }.uniq).to eq([ "fixture=connection" ])
|
|
1351
|
+
expect(reports.map { |report| report.values_at(:part_number, :part_bytes) }.sort).to eq([ [ 1, 8 * mib ], [ 2, 8 * mib ], [ 3, mib + 3 ] ])
|
|
1352
|
+
expect(begun).to eq([ %w[1 5] ])
|
|
1353
|
+
expect(ended.map { |params| params["size"] }).to eq([ content.bytesize.to_s ])
|
|
1354
|
+
expect(ended.first["etags"].map { |etag| etag.values_at("part", "etag") }).to eq((1..3).map { |number| [ number.to_s, "\"etag-#{number}\"" ] })
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
it "refuses, before reading anything, a declared-size stream no plan in its available parts could send within buffer_bytes" do
|
|
1358
|
+
reads = []
|
|
1359
|
+
stream = Object.new
|
|
1360
|
+
stream.define_singleton_method(:read) do |length|
|
|
1361
|
+
reads << length
|
|
1362
|
+
nil
|
|
1363
|
+
end
|
|
1364
|
+
# One part is available for 1 GiB and a byte, more than the SDK's own largest part. Through a caller's client a
|
|
1365
|
+
# stream's part needs twice its size, so 16 MiB of buffer_bytes holds parts of 8 MiB at most: the API's 5 MiB parts
|
|
1366
|
+
# would fit, but could no more send the file in one part than any other plan.
|
|
1367
|
+
client = s3_client(grant: ->(number, _params) { s3_grant(number, available_parts: 1) })
|
|
1368
|
+
|
|
1369
|
+
expect { described_class.upload_io(stream, "report.bin", { client: client }, size: (1024 * mib) + 1, transfer: { buffer_bytes: 16 * mib }) }
|
|
1370
|
+
.to raise_error(Files::Transfer::BufferLimitError)
|
|
1371
|
+
expect(reads).to be_empty
|
|
1372
|
+
expect([ sent, delivered, ended ]).to all(be_empty)
|
|
1373
|
+
expect(begun).to eq([ %w[1 5] ])
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
it "keeps each part's offset and size through later answers, renewals and retries recommending other sizes" do
|
|
1377
|
+
Files.max_network_retries = 1
|
|
1378
|
+
bytes = Random.new(26).bytes((26 * mib) + 3)
|
|
1379
|
+
# The first answer recommends 6 MiB, with part 2's URL already expired; its renewal recommends 9 MiB, and the
|
|
1380
|
+
# answer for part 6 on, 7 MiB. S3 refuses part 3's first attempt.
|
|
1381
|
+
grant = lambda do |number, params|
|
|
1382
|
+
if params["parts"] == "1"
|
|
1383
|
+
s3_grant(number, hint: 9 * mib)
|
|
1384
|
+
elsif params["part"] == "1"
|
|
1385
|
+
s3_grant(number, hint: 6 * mib, upload_uri: s3_url(number, expired: number == 2))
|
|
1386
|
+
else
|
|
1387
|
+
s3_grant(number, hint: 7 * mib)
|
|
1388
|
+
end
|
|
1389
|
+
end
|
|
1390
|
+
client = s3_client(grant: grant, refuse: ->(number, attempt) { number == "3" && attempt == 1 })
|
|
1391
|
+
|
|
1392
|
+
# Through a caller's client a part is read whole, so buffer_bytes of 5 MiB holds parts of 5 MiB at most.
|
|
1393
|
+
upload_with(client, bytes, transfer: { buffer_bytes: 5 * mib })
|
|
1394
|
+
|
|
1395
|
+
planned = (1..6).map { |number| part_of(s3_url(number), bytes.byteslice((number - 1) * 5 * mib, 5 * mib)) }
|
|
1396
|
+
expect(parts_sent_to_s3.sort).to eq((planned + [ planned[2] ]).sort)
|
|
1397
|
+
expect(begun.sort).to eq([ %w[1 5], %w[2 1], %w[6 5] ])
|
|
1398
|
+
expect(ended.first["etags"].map { |etag| etag["part"] }).to eq(%w[1 2 3 4 5 6])
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
it "refuses a plan whose parts buffer_bytes cannot hold before sending anything" do
|
|
1402
|
+
expect { upload_with(s3_client, content, transfer: { buffer_bytes: 4 * mib }) }.to raise_error(Files::Transfer::BufferLimitError)
|
|
1403
|
+
expect(sent).to be_empty
|
|
1404
|
+
expect(begun).to eq([ %w[1 5] ])
|
|
1405
|
+
expect(ended).to be_empty
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
it "stops before sending a later part that is for another S3 upload" do
|
|
1409
|
+
bytes = Random.new(26).bytes((26 * mib) + 3)
|
|
1410
|
+
client = s3_client(grant: lambda { |number, params|
|
|
1411
|
+
params["part"] == "1" ? s3_grant(number) : s3_grant(number, upload_uri: s3_url(number, upload_id: "s3-upload-2"))
|
|
1412
|
+
}
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
expect { upload_with(client, bytes, transfer: { buffer_bytes: 5 * mib }) }
|
|
1416
|
+
.to raise_error(Files::APIError, "The upload instructions changed during the upload")
|
|
1417
|
+
expect(begun.last).to eq(%w[6 5])
|
|
1418
|
+
expect(numbers_sent).not_to include("6")
|
|
1419
|
+
expect(ended).to be_empty
|
|
1420
|
+
end
|
|
1421
|
+
|
|
1422
|
+
it "refuses a renewal whose URL newly signs the part's length, sending nothing for that part" do
|
|
1423
|
+
grant = lambda do |number, params|
|
|
1424
|
+
if params["parts"] == "1"
|
|
1425
|
+
s3_grant(number, upload_uri: s3_url(number, signed: "content-length%3Bhost"))
|
|
1426
|
+
else
|
|
1427
|
+
s3_grant(number, upload_uri: s3_url(number, expired: number == 2))
|
|
1428
|
+
end
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
expect { upload_with(s3_client(grant: grant), content, transfer: true) }.to raise_error(Files::APIError, "The renewed upload instructions changed")
|
|
1432
|
+
expect(begun).to include(%w[2 1])
|
|
1433
|
+
expect(numbers_sent).not_to include("2")
|
|
1434
|
+
expect(ended).to be_empty
|
|
1435
|
+
end
|
|
1436
|
+
|
|
1437
|
+
it "pauses with a checkpoint keeping the planned parts and only a digest of the S3 upload, and resumes them whatever the API recommends" do
|
|
1438
|
+
control = Files::Transfer::Control.new
|
|
1439
|
+
hint = 5 * mib
|
|
1440
|
+
client = s3_client(grant: ->(number, _params) { s3_grant(number, hint: hint) })
|
|
1441
|
+
|
|
1442
|
+
with_local_file(content) do |path|
|
|
1443
|
+
paused = nil
|
|
1444
|
+
expect { upload(path, { client: client }, progress: ->(_report) { control.pause }, transfer: { control: control }) }
|
|
1445
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1446
|
+
checkpoint = paused.checkpoint
|
|
1447
|
+
expect(checkpoint.completed_bytes).to eq(8 * mib)
|
|
1448
|
+
expect(checkpoint.state[:plan]).to match(mode: :local, route: a_string_matching(/\Asha256:\h{64}\z/), partsize: 8 * mib)
|
|
1449
|
+
expect(checkpoint.state[:layout].map { |part| part.first(4) })
|
|
1450
|
+
.to eq([ [ 1, 0, 8 * mib, 8 * mib ], [ 2, 8 * mib, 8 * mib, 8 * mib ], [ 3, 16 * mib, mib + 3, 8 * mib ] ])
|
|
1451
|
+
expect([ checkpoint.state, checkpoint.inspect ].inspect).not_to include("s3-upload-1", "amazonaws", "X-Amz", "AKIDFIXTURE", "uploads/")
|
|
1452
|
+
|
|
1453
|
+
# The renewals of the resumed parts recommend 6 MiB.
|
|
1454
|
+
hint = 6 * mib
|
|
1455
|
+
described_class.resume_upload(checkpoint, { client: client }, transfer: true)
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
expect(parts_sent_to_s3.sort).to eq([ part_of(s3_url(1), content.byteslice(0, 8 * mib)), part_of(s3_url(2), content.byteslice(8 * mib, 8 * mib)),
|
|
1459
|
+
part_of(s3_url(3), content.byteslice(16 * mib, 8 * mib)) ]
|
|
1460
|
+
)
|
|
1461
|
+
expect(begun.drop(1)).to eq([ %w[2 1], %w[3 1] ])
|
|
1462
|
+
expect(ended.first["etags"].map { |etag| etag["part"] }).to eq(%w[1 2 3])
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
# A 10-byte file the API would send in parts of 4 bytes, and an upload sizing its own parts in one.
|
|
1466
|
+
direct = { version: 1, server_name: "agent.example.test", addresses: [ "192.0.2.1" ], direct_uri: "https://agent.example.test",
|
|
1467
|
+
ca_pem: "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----\n" }
|
|
1468
|
+
[
|
|
1469
|
+
[ "a regional endpoint", ->(_number) { {} }, %w[abcdefghij] ],
|
|
1470
|
+
[ "the global endpoint", ->(number) { { upload_uri: s3_url(number, host: "reports.s3.amazonaws.com") } }, %w[abcdefghij] ],
|
|
1471
|
+
[ "a dashed regional endpoint", ->(number) { { upload_uri: s3_url(number, host: "reports.s3-us-west-2.amazonaws.com") } }, %w[abcdefghij] ],
|
|
1472
|
+
[ "a dual-stack endpoint", ->(number) { { upload_uri: s3_url(number, host: "reports.s3.dualstack.eu-central-1.amazonaws.com") } }, %w[abcdefghij] ],
|
|
1473
|
+
[ "a path-style URL", ->(number) { { upload_uri: s3_url(number, host: "s3.us-east-1.amazonaws.com/reports") } }, %w[abcdefghij] ],
|
|
1474
|
+
[ "an endpoint in China", ->(number) { { upload_uri: s3_url(number, host: "reports.s3.cn-north-1.amazonaws.com.cn") } }, %w[abcdefghij] ],
|
|
1475
|
+
[ "parts that may not go at once", ->(_number) { { parallel_parts: false } }, %w[abcdefghij] ],
|
|
1476
|
+
[ "an S3-compatible service", ->(number) { { upload_uri: s3_url(number, host: "reports.storage.example.test") } }, %w[abcd efgh ij] ],
|
|
1477
|
+
[ "a host that only resembles S3's", ->(number) { { upload_uri: s3_url(number, host: "reports.s3.us-east-1.amazonaws.com.example.test") } }, %w[abcd efgh ij] ],
|
|
1478
|
+
[ "an accelerate endpoint", ->(number) { { upload_uri: s3_url(number, host: "reports.s3-accelerate.amazonaws.com") } }, %w[abcd efgh ij] ],
|
|
1479
|
+
[ "a FIPS endpoint", ->(number) { { upload_uri: s3_url(number, host: "reports.s3-fips.us-east-1.amazonaws.com") } }, %w[abcd efgh ij] ],
|
|
1480
|
+
[ "plain HTTP", ->(number) { { upload_uri: s3_url(number).sub("https://", "http://") } }, %w[abcd efgh ij] ],
|
|
1481
|
+
[ "a port of its own", ->(number) { { upload_uri: s3_url(number).sub(".amazonaws.com/", ".amazonaws.com:8443/") } }, %w[abcd efgh ij] ],
|
|
1482
|
+
[ "a signed Content-Length", ->(number) { { upload_uri: s3_url(number, signed: "content-length%3Bhost") } }, %w[abcd efgh ij] ],
|
|
1483
|
+
[ "a checksum in the URL", ->(number) { { upload_uri: s3_url(number, tail: "&x-amz-checksum-crc32=AAAAAA%3D%3D") } }, %w[abcd efgh ij] ],
|
|
1484
|
+
[ "a header to send", ->(_number) { { headers: { "x-amz-checksum-crc32" => "AAAAAA==" } } }, %w[abcd efgh ij] ],
|
|
1485
|
+
[ "parameters to send", ->(_number) { { parameters: { "acl" => "private" } } }, %w[abcd efgh ij] ],
|
|
1486
|
+
[ "a direct connection", ->(_number) { { direct_connection_info: direct } }, %w[abcd efgh ij] ],
|
|
1487
|
+
[ "another upload for a later part", ->(number) { number == 2 ? { upload_uri: s3_url(number, upload_id: "s3-upload-2") } : {} }, %w[abcd efgh ij] ]
|
|
1488
|
+
].each do |form, change, bodies|
|
|
1489
|
+
it "#{bodies.size == 1 ? 'sizes the parts itself' : 'leaves the part sizes to the API'} for #{form}, however parallel" do
|
|
1490
|
+
answer = ->(number) { s3_grant(number, hint: 4, **instance_exec(number, &change)) }
|
|
1491
|
+
|
|
1492
|
+
upload_with(s3_client(grant: ->(number, _params) { answer.call(number) }), "abcdefghij", transfer: true)
|
|
1493
|
+
|
|
1494
|
+
expect(parts_sent_to_s3.sort).to eq(bodies.each_with_index.map { |body, index| part_of(answer.call(index + 1)[:upload_uri], body) }.sort)
|
|
1495
|
+
expect(ended.size).to eq(1)
|
|
1496
|
+
end
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
# A file of 1 GiB and a byte with one part available needs a part larger than the SDK's own largest, and through a
|
|
1500
|
+
# caller's client 16 MiB of buffer_bytes could not hold the API's part either. The file is sparse, and each answer
|
|
1501
|
+
# pauses the upload before it is given, so no part is admitted and nothing of the file is read or sent.
|
|
1502
|
+
it "keeps a plan it refused out of the checkpoint of a pause that came first, and resumes choosing the sizes afresh, whatever plan it paused with" do
|
|
1503
|
+
control = Files::Transfer::Control.new
|
|
1504
|
+
size = (1024 * mib) + 1
|
|
1505
|
+
answer = ->(number) { s3_grant(number, available_parts: 1) }
|
|
1506
|
+
client = s3_client(grant: lambda { |number, _params|
|
|
1507
|
+
control.pause
|
|
1508
|
+
answer.call(number)
|
|
1509
|
+
}
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
Dir.mktmpdir do |dir|
|
|
1513
|
+
path = File.join(dir, "report.bin")
|
|
1514
|
+
File.open(path, "wb") { |file| file.truncate(size) }
|
|
1515
|
+
identity = Files::Transfer::Checkpoint.identity(File.stat(path))
|
|
1516
|
+
paused = nil
|
|
1517
|
+
|
|
1518
|
+
expect { upload(path, { client: client }, transfer: { buffer_bytes: 16 * mib, control: control }) }
|
|
1519
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1520
|
+
checkpoint = paused.checkpoint
|
|
1521
|
+
expect([ checkpoint.local_path, checkpoint.source, checkpoint.completed_bytes, checkpoint.total_bytes ]).to eq([ path, identity, 0, size ])
|
|
1522
|
+
expect(checkpoint.state.values_at(:plan, :ref, :layout)).to eq([ { mode: :api }, nil, [] ])
|
|
1523
|
+
|
|
1524
|
+
# Resumed, the upload is answered with 10,000 parts available, which it plans 8 MiB parts in, but with no usable
|
|
1525
|
+
# reference: the pause, which came first, is raised in place of its refusal, with a checkpoint keeping that plan
|
|
1526
|
+
# but no reference and no parts.
|
|
1527
|
+
control = Files::Transfer::Control.new
|
|
1528
|
+
answer = ->(number) { s3_grant(number, ref: "") }
|
|
1529
|
+
expect { described_class.resume_upload(checkpoint, { client: client }, transfer: { buffer_bytes: 16 * mib, control: control }) }
|
|
1530
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1531
|
+
checkpoint = paused.checkpoint
|
|
1532
|
+
expect(checkpoint.state.values_at(:plan, :ref, :layout))
|
|
1533
|
+
.to match([ { mode: :local, route: a_string_matching(/\Asha256:\h{64}\z/), partsize: 8 * mib }, nil, [] ])
|
|
1534
|
+
|
|
1535
|
+
# Resumed with room for one part of the whole file, which the refused size would have planned, and one part
|
|
1536
|
+
# available again, the upload chooses afresh, keeping neither the route nor the size it paused with: it leaves
|
|
1537
|
+
# the sizes to the API, within the SDK's own largest part, until its first answer pauses it again.
|
|
1538
|
+
control = Files::Transfer::Control.new
|
|
1539
|
+
answer = ->(number) { s3_grant(number, available_parts: 1) }
|
|
1540
|
+
expect { described_class.resume_upload(checkpoint, { client: client }, transfer: { buffer_bytes: 2 * size, control: control }) }
|
|
1541
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1542
|
+
resumed = paused.checkpoint
|
|
1543
|
+
expect([ resumed.local_path, resumed.source, resumed.completed_bytes ]).to eq([ path, identity, 0 ])
|
|
1544
|
+
expect(resumed.state.values_at(:plan, :ref)).to eq([ { mode: :api }, "upload-ref" ])
|
|
1545
|
+
expect(resumed.state[:layout].map { |part| part[2] }).to eq([ 5 * mib ] * 5)
|
|
1546
|
+
end
|
|
1547
|
+
expect([ sent, delivered, ended ]).to all(be_empty)
|
|
1548
|
+
expect(begun).to eq([ %w[1 5] ] * 3)
|
|
1549
|
+
end
|
|
1550
|
+
|
|
1551
|
+
# An upload resumed from a checkpoint paused before any reference chooses its plan afresh: when the answer it then
|
|
1552
|
+
# gets is not for S3 at all, it keeps no route of the plan it paused with, and sends the parts as the API sizes them.
|
|
1553
|
+
it "keeps no route a resume's fresh answer does not give" do
|
|
1554
|
+
control = Files::Transfer::Control.new
|
|
1555
|
+
compatible = ->(number) { s3_url(number, host: "reports.storage.example.test") }
|
|
1556
|
+
# The first answer, which the pause beats, gives no usable reference; the resume's is for an S3-compatible service.
|
|
1557
|
+
answer = ->(number) { s3_grant(number, hint: 4, ref: "") }
|
|
1558
|
+
client = s3_client(grant: lambda { |number, _params|
|
|
1559
|
+
control&.pause
|
|
1560
|
+
answer.call(number)
|
|
1561
|
+
}
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
with_local_file("abcdefghij") do |path|
|
|
1565
|
+
paused = nil
|
|
1566
|
+
expect { upload(path, { client: client }, transfer: { control: control }) }.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1567
|
+
expect(paused.checkpoint.state.values_at(:plan, :ref, :layout))
|
|
1568
|
+
.to match([ { mode: :local, route: a_string_matching(/\Asha256:\h{64}\z/), partsize: 8 * mib }, nil, [] ])
|
|
1569
|
+
|
|
1570
|
+
control = nil
|
|
1571
|
+
answer = ->(number) { s3_grant(number, hint: 4, upload_uri: compatible.call(number)) }
|
|
1572
|
+
described_class.resume_upload(paused.checkpoint, { client: client }, transfer: true)
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1575
|
+
expect(parts_sent_to_s3.sort).to eq([ part_of(compatible.call(1), "abcd"), part_of(compatible.call(2), "efgh"), part_of(compatible.call(3), "ij") ].sort)
|
|
1576
|
+
expect(begun).to eq([ %w[1 5] ] * 2)
|
|
1577
|
+
expect(ended.map { |params| params["size"] }).to eq([ "10" ])
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
# Files far larger than any example could send, planned by the SDK's own limits.
|
|
1581
|
+
it "plans within the SDK's own limits, and not at all beyond them" do
|
|
1582
|
+
plan = Files::Transfer::UploadPlan
|
|
1583
|
+
gib = 1024 * mib
|
|
1584
|
+
tib = 1024 * gib
|
|
1585
|
+
unlimited = Float::INFINITY
|
|
1586
|
+
|
|
1587
|
+
expect([ plan::MIN_PART_BYTES, plan::MAX_PART_BYTES, plan::MAX_PARTS, plan::MAX_FILE_BYTES ]).to eq([ 5 * mib, gib, 10_000, 5 * tib ])
|
|
1588
|
+
expect([ plan.part_size(content.bytesize, 10_000, unlimited), plan.part_size(content.bytesize, nil, unlimited) ]).to eq([ 8 * mib, 8 * mib ])
|
|
1589
|
+
# Only two parts available, or 100 GiB in 10,000 parts, take larger parts, in whole MiB; more than 10,000 available
|
|
1590
|
+
# parts count as 10,000.
|
|
1591
|
+
expect(plan.part_size(content.bytesize, 2, unlimited)).to eq(9 * mib)
|
|
1592
|
+
expect([ plan.part_size(100 * gib, 10_000, unlimited), plan.part_size(100 * gib, 20_000, unlimited) ]).to eq([ 11 * mib, 11 * mib ])
|
|
1593
|
+
expect([ plan.part_size(5 * tib, 10_000, unlimited), plan.part_size(100 * gib, 100, unlimited) ]).to eq([ 525 * mib, gib ])
|
|
1594
|
+
expect([ plan.part_size((5 * tib) + 1, 10_000, unlimited), plan.part_size((100 * gib) + 1, 100, unlimited) ]).to eq([ nil, nil ])
|
|
1595
|
+
# Unless buffer_bytes could not hold the API's parts either: such a file is refused, with no size at all.
|
|
1596
|
+
expect { plan.part_size((100 * gib) + 1, 100, 8 * mib) }.to raise_error(Files::Transfer::BufferLimitError)
|
|
1597
|
+
# A part buffer_bytes can hold, but never under 5 MiB unless the file is one part.
|
|
1598
|
+
expect([ plan.part_size(content.bytesize, 10_000, 6 * mib), plan.part_size(content.bytesize, 10_000, 4 * mib) ]).to eq([ 6 * mib, 5 * mib ])
|
|
1599
|
+
expect(plan.part_size(3 * mib, 10_000, mib)).to eq(3 * mib)
|
|
1600
|
+
end
|
|
1601
|
+
end
|
|
1602
|
+
end
|