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,1102 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "logger"
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require_relative "../support/transfer_server"
|
|
7
|
+
|
|
8
|
+
# Adaptive downloads from a real loopback server playing storage, and the Files.com API where a download needs a
|
|
9
|
+
# fresh URL. The job's own connections make every request below; only the server's answers are scripted.
|
|
10
|
+
RSpec.describe Files::File, "adaptive downloads" do
|
|
11
|
+
# Ten full 64 KiB ranges and a short one, with every byte value.
|
|
12
|
+
let(:payload) { Random.new(7).bytes((10 * 65_536) + 1000) }
|
|
13
|
+
# A quarter of it is the 64 KiB range size.
|
|
14
|
+
let(:transfer) { { buffer_bytes: 262_144 } }
|
|
15
|
+
let(:sink) { StringIO.new("".b) }
|
|
16
|
+
let(:servers) { [] }
|
|
17
|
+
|
|
18
|
+
around do |example|
|
|
19
|
+
original = [ Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id ]
|
|
20
|
+
Files.api_key = "fixture-api-key"
|
|
21
|
+
Files.max_network_retries = 0
|
|
22
|
+
Files.session_id = "fixture-session"
|
|
23
|
+
example.run
|
|
24
|
+
ensure
|
|
25
|
+
Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id = original
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
after { servers.each(&:close) }
|
|
29
|
+
|
|
30
|
+
# Storage for report.bin: a Range is answered 206 with its exact Content-Range and etag, a request without one 200
|
|
31
|
+
# with all of content. With hold, each range after the opening one waits until that many requests have been in
|
|
32
|
+
# flight at once (at most five seconds). answer, when given, receives each request and that default response and
|
|
33
|
+
# returns what to send.
|
|
34
|
+
def storage_server(content = payload, etag: "\"v1\"", hold: nil, answer: nil)
|
|
35
|
+
store = TransferServer.new(concurrent: true) do |request|
|
|
36
|
+
range = request.headers["range"]
|
|
37
|
+
store.wait_for_peak(hold) if hold && range && !range.start_with?("bytes=0-")
|
|
38
|
+
default = stored(content, range, etag)
|
|
39
|
+
answer ? answer.call(request, default) : default
|
|
40
|
+
end
|
|
41
|
+
servers << store
|
|
42
|
+
store
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stored(content, range, etag)
|
|
46
|
+
return TransferServer.response("200 OK", { "ETag" => etag, "Content-Length" => content.bytesize }, content) unless range
|
|
47
|
+
|
|
48
|
+
first, last = range.delete_prefix("bytes=").split("-").map(&:to_i)
|
|
49
|
+
last = [ last, content.bytesize - 1 ].min
|
|
50
|
+
body = content.byteslice(first..last)
|
|
51
|
+
TransferServer.response("206 Partial Content", { "ETag" => etag, "Content-Range" => "bytes #{first}-#{last}/#{content.bytesize}", "Content-Length" => body.bytesize }, body)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The API, made Files.base_url, answering each file lookup with a download URL on store.
|
|
55
|
+
def api_server(store)
|
|
56
|
+
api = TransferServer.new(concurrent: true) do |_request|
|
|
57
|
+
body = JSON.generate(path: "report.bin", type: "file", download_uri: store.url("/objects/report.bin"))
|
|
58
|
+
TransferServer.response("200 OK", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
|
|
59
|
+
end
|
|
60
|
+
servers << api
|
|
61
|
+
Files.base_url = api.url("")
|
|
62
|
+
api
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def remote_file(store)
|
|
66
|
+
described_class.new(path: "report.bin", download_uri: store.url("/objects/report.bin"))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ranges(store)
|
|
70
|
+
store.requests.map { |request| request.headers["range"] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Waits until the block is true, for at most five seconds.
|
|
74
|
+
def wait_until
|
|
75
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
76
|
+
sleep 0.01 until yield || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A download_client that sends through a connection of its own: a logger of its own makes it a caller's client.
|
|
80
|
+
def own_client
|
|
81
|
+
Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true), logger: Logger.new(StringIO.new))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe "a bound version" do
|
|
85
|
+
it "fetches ranges at once with If-Match, within the buffer, and writes them in order" do
|
|
86
|
+
store = storage_server(hold: 4)
|
|
87
|
+
|
|
88
|
+
Files::Transfer::Job.open(**transfer) do |job|
|
|
89
|
+
remote_file(store).download_content(sink, transfer: job)
|
|
90
|
+
expect(job.held_bytes).to eq(0)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
expect(sink.string).to eq(payload)
|
|
94
|
+
expect(store.peak_in_flight).to eq(4)
|
|
95
|
+
opening, *rest = store.requests
|
|
96
|
+
expect(opening.headers.values_at("range", "accept-encoding", "if-match")).to eq([ "bytes=0-65535", "identity", nil ])
|
|
97
|
+
expect(rest.map { |request| request.headers.values_at("accept-encoding", "if-match") }.uniq).to eq([ [ "identity", "\"v1\"" ] ])
|
|
98
|
+
expect(rest.map { |request| request.headers["range"] }.sort_by { |range| range[/\d+/].to_i })
|
|
99
|
+
.to eq((1..10).map { |index| "bytes=#{index * 65_536}-#{[ ((index + 1) * 65_536) - 1, payload.bytesize - 1 ].min}" })
|
|
100
|
+
expect(sink).not_to be_closed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "stops with VersionChangedError when storage no longer has the version, keeping what was written and closing the IO" do
|
|
104
|
+
store = storage_server(answer: ->(request, default) { request.headers["if-match"] ? TransferServer.response("412 Precondition Failed", { "Content-Length" => 0 }) : default })
|
|
105
|
+
|
|
106
|
+
expect { remote_file(store).download_content(sink, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
|
|
107
|
+
expect(sink.string).to eq(payload.byteslice(0, 65_536))
|
|
108
|
+
expect(sink).to be_closed
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "refuses a range answered for other bytes, without retrying it" do
|
|
112
|
+
Files.max_network_retries = 2
|
|
113
|
+
wrong = TransferServer.response("206 Partial Content", { "ETag" => "\"v1\"", "Content-Range" => "bytes 0-9/#{payload.bytesize}", "Content-Length" => 10 }, payload.byteslice(0, 10))
|
|
114
|
+
store = storage_server(answer: ->(request, default) { request.headers["if-match"] ? wrong : default })
|
|
115
|
+
|
|
116
|
+
expect { remote_file(store).download_content(sink, transfer: transfer) }.to raise_error(Files::APIError, "Transfer response did not match the request")
|
|
117
|
+
expect(ranges(store).tally.values.uniq).to eq([ 1 ])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "retries a range storage throttled and writes each byte once" do
|
|
121
|
+
Files.max_network_retries = 1
|
|
122
|
+
lock = Mutex.new
|
|
123
|
+
throttled = false
|
|
124
|
+
store = storage_server(answer: lambda { |request, default|
|
|
125
|
+
first = lock.synchronize { request.headers["range"] == "bytes=65536-131071" && !throttled && (throttled = true) }
|
|
126
|
+
first ? TransferServer.response("503 Slow Down", { "Retry-After" => "0", "Content-Length" => 0 }) : default
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
remote_file(store).download_content(sink, transfer: transfer)
|
|
131
|
+
|
|
132
|
+
expect(sink.string).to eq(payload)
|
|
133
|
+
expect(ranges(store).count("bytes=65536-131071")).to eq(2)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "reports progress on the caller's thread after each write, and what the job accepted" do
|
|
137
|
+
store = storage_server
|
|
138
|
+
reports = []
|
|
139
|
+
threads = []
|
|
140
|
+
|
|
141
|
+
Files::Transfer::Job.open(**transfer) do |job|
|
|
142
|
+
remote_file(store).download_content(sink, progress: ->(report) { (reports << report) && (threads << Thread.current) }, transfer: job)
|
|
143
|
+
|
|
144
|
+
diagnostics = job.diagnostics
|
|
145
|
+
expect(diagnostics.values_at(:accepted_bytes, :requests, :retries)).to eq([ payload.bytesize, 0, 0 ])
|
|
146
|
+
expect(diagnostics[:elapsed_seconds]).to be > 0
|
|
147
|
+
expect(diagnostics[:goodput_bytes_per_second]).to be > 0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
expect(sink.string).to eq(payload)
|
|
151
|
+
expect(threads.uniq).to eq([ Thread.current ])
|
|
152
|
+
written = reports.map { |report| report[:written_bytes] }
|
|
153
|
+
expect(written).to eq(written.sort)
|
|
154
|
+
expect(written.last).to eq(payload.bytesize)
|
|
155
|
+
expect(reports.map { |report| report[:total_bytes] }.uniq).to eq([ payload.bytesize ])
|
|
156
|
+
expect(reports.map { |report| report[:bytes_per_second] }).to all(be >= 0)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "refuses progress without transfer" do
|
|
160
|
+
expect { remote_file(storage_server).download_content(sink, progress: ->(_report) {}) }.to raise_error(Files::InvalidParameterError)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "refuses a download_client whose adapter cannot stream before requesting or writing anything, and downloads through it without transfer:" do
|
|
164
|
+
requests = []
|
|
165
|
+
stubs = Faraday::Adapter::Test::Stubs.new
|
|
166
|
+
stubs.get("/objects/report.bin") do |env|
|
|
167
|
+
requests << env.request_headers["Range"]
|
|
168
|
+
[ 200, { "ETag" => "\"v1\"", "Content-Length" => payload.bytesize.to_s }, payload ]
|
|
169
|
+
end
|
|
170
|
+
Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Faraday.new { |builder| builder.adapter :test, stubs })
|
|
171
|
+
remote = described_class.new(path: "report.bin", download_uri: "https://storage.example.test/objects/report.bin")
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
Dir.mktmpdir do |dir|
|
|
175
|
+
local_path = File.join(dir, "report.bin")
|
|
176
|
+
File.binwrite(local_path, "kept")
|
|
177
|
+
expect { remote.download_file(local_path, transfer: transfer) }.to raise_error(Files::Transfer::BufferLimitError, /cannot stream/)
|
|
178
|
+
expect(File.binread(local_path)).to eq("kept")
|
|
179
|
+
end
|
|
180
|
+
expect { remote.download_content(sink, transfer: transfer) }.to raise_error(Files::Transfer::BufferLimitError)
|
|
181
|
+
expect(requests).to be_empty
|
|
182
|
+
expect(sink.string).to be_empty
|
|
183
|
+
expect(sink).not_to be_closed
|
|
184
|
+
|
|
185
|
+
remote.download_content(sink)
|
|
186
|
+
ensure
|
|
187
|
+
Thread.current[:files_api_client_download_client] = nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
expect(sink.string).to eq(payload)
|
|
191
|
+
expect(requests).to eq([ nil ])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "streams every request through a download_client with its own logger, over its adapter, one at a time" do
|
|
195
|
+
store = storage_server
|
|
196
|
+
log = StringIO.new
|
|
197
|
+
Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true), logger: Logger.new(log))
|
|
198
|
+
|
|
199
|
+
begin
|
|
200
|
+
Files::Transfer::Job.open(**transfer) do |job|
|
|
201
|
+
remote_file(store).download_content(sink, transfer: job)
|
|
202
|
+
|
|
203
|
+
expect(job.diagnostics[:notes]).to include(a_string_including("one request at a time"))
|
|
204
|
+
expect(job.held_bytes).to eq(0)
|
|
205
|
+
end
|
|
206
|
+
ensure
|
|
207
|
+
Thread.current[:files_api_client_download_client] = nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
expect(sink.string).to eq(payload)
|
|
211
|
+
expect(store.peak_in_flight).to eq(1)
|
|
212
|
+
expect(ranges(store).size).to eq(11)
|
|
213
|
+
expect(log.string).to include("[transfer]")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "streams a whole response larger than buffer_bytes through such a client, and refuses the web app's before writing it" do
|
|
217
|
+
Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true), logger: Logger.new(StringIO.new))
|
|
218
|
+
|
|
219
|
+
begin
|
|
220
|
+
whole = storage_server(etag: "W/\"v1\"")
|
|
221
|
+
remote_file(whole).download_content(sink, transfer: transfer)
|
|
222
|
+
expect(sink.string).to eq(payload)
|
|
223
|
+
expect(ranges(whole)).to eq([ "bytes=0-65535", nil ])
|
|
224
|
+
|
|
225
|
+
page = "<html>Sign in</html>"
|
|
226
|
+
frontend = storage_server(answer: lambda { |_request, _default|
|
|
227
|
+
TransferServer.response("206 Partial Content", { "X-Files-Frontend-App" => "true", "ETag" => "\"v1\"", "Content-Range" => "bytes 0-19/20", "Content-Length" => 20 }, page)
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
refused = StringIO.new("".b)
|
|
231
|
+
expect { remote_file(frontend).download_content(refused, transfer: transfer) }.to raise_error(Files::Transfer::FrontendResponseError)
|
|
232
|
+
expect(refused.string).to be_empty
|
|
233
|
+
expect(refused).to be_closed
|
|
234
|
+
ensure
|
|
235
|
+
Thread.current[:files_api_client_download_client] = nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "streams through a download_client whose adapter has a configuration block of its own, calling it for each connection" do
|
|
240
|
+
store = storage_server
|
|
241
|
+
configured = []
|
|
242
|
+
conn = Files::ApiClient.build_default_conn(force_net_http: true)
|
|
243
|
+
conn.adapter(:net_http) { |http| configured << http.port }
|
|
244
|
+
Thread.current[:files_api_client_download_client] = Files::ApiClient.new(conn)
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
Files::Transfer::Job.open(**transfer) do |job|
|
|
248
|
+
remote_file(store).download_content(sink, transfer: job)
|
|
249
|
+
|
|
250
|
+
expect(job.diagnostics[:notes]).to include(a_string_including("one request at a time"))
|
|
251
|
+
end
|
|
252
|
+
ensure
|
|
253
|
+
Thread.current[:files_api_client_download_client] = nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
expect(sink.string).to eq(payload)
|
|
257
|
+
expect(configured.uniq).to eq([ URI(store.url("")).port ])
|
|
258
|
+
expect(configured.size).to eq(store.requests.size)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "keeps a download_client made after Files.proxy changed on its own proxy in a job made before, and uses the job's own connections when their proxies match" do
|
|
262
|
+
store = storage_server
|
|
263
|
+
# A proxy that answers each request itself, as storage does, and records the whole URL it was asked for.
|
|
264
|
+
proxy = storage_server
|
|
265
|
+
original = Files.proxy
|
|
266
|
+
later = StringIO.new("".b)
|
|
267
|
+
notes = []
|
|
268
|
+
# The thread keeps its default client for good, so it is made, as the downloads' API context will find it,
|
|
269
|
+
# before Files.proxy changes.
|
|
270
|
+
Files::ApiClient.default_client
|
|
271
|
+
|
|
272
|
+
begin
|
|
273
|
+
Files::Transfer::Job.open(**transfer) do |job|
|
|
274
|
+
Files.proxy = proxy.url("")
|
|
275
|
+
Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true))
|
|
276
|
+
remote_file(store).download_content(sink, transfer: job)
|
|
277
|
+
notes << job.diagnostics[:notes]
|
|
278
|
+
end
|
|
279
|
+
Files::Transfer::Job.open(**transfer) do |job|
|
|
280
|
+
remote_file(store).download_content(later, transfer: job)
|
|
281
|
+
notes << job.diagnostics[:notes]
|
|
282
|
+
end
|
|
283
|
+
ensure
|
|
284
|
+
Files.proxy = original
|
|
285
|
+
Thread.current[:files_api_client_download_client] = nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
expect([ sink.string, later.string ]).to eq([ payload, payload ])
|
|
289
|
+
# The job made before the change sent through the client, one request at a time; the one made after, over its own
|
|
290
|
+
# connections. Both went through the client's proxy, never straight to storage.
|
|
291
|
+
expect(notes.first).to include(a_string_including("one request at a time"))
|
|
292
|
+
expect(notes.last).to be_empty
|
|
293
|
+
expect(store.requests).to be_empty
|
|
294
|
+
expect(proxy.requests.map(&:target).uniq).to eq([ store.url("/objects/report.bin") ])
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it "holds a download's opening request to an origin another's throttle holds back, through a client of its own as over the job's connections" do
|
|
298
|
+
Files.max_network_retries = 1
|
|
299
|
+
lock = Mutex.new
|
|
300
|
+
arrivals = []
|
|
301
|
+
answer = Queue.new
|
|
302
|
+
store = storage_server(answer: lambda { |_request, default|
|
|
303
|
+
first = lock.synchronize { (arrivals << Process.clock_gettime(Process::CLOCK_MONOTONIC)).size == 1 }
|
|
304
|
+
next default unless first
|
|
305
|
+
|
|
306
|
+
# The first download's opening request is answered only once the second's waits for the job's one permit.
|
|
307
|
+
answer.pop
|
|
308
|
+
TransferServer.response("503 Slow Down", { "Retry-After" => "1", "Content-Length" => 0 })
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
sinks = Array.new(3) { StringIO.new("".b) }
|
|
312
|
+
|
|
313
|
+
Files::Transfer::Job.open(**transfer, concurrency: 1, max_files: 3) do |job|
|
|
314
|
+
# The first download stops just after its throttled opening request gives the permit back, until the second
|
|
315
|
+
# download's opening request reaches storage or half a second has passed: a throttle published only after the
|
|
316
|
+
# permit was given back would let that waiting request through first.
|
|
317
|
+
settling = true
|
|
318
|
+
job.define_singleton_method(:release_request) do |*args|
|
|
319
|
+
super(*args).tap do
|
|
320
|
+
next unless settling
|
|
321
|
+
|
|
322
|
+
settling = false
|
|
323
|
+
store.wait_for_requests(2, timeout: 0.5)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
# Over the job's own connections, the first download's opening request takes the one permit.
|
|
327
|
+
owned = Thread.new { remote_file(store).download_content(sinks[0], transfer: job) }
|
|
328
|
+
store.wait_for_requests(1)
|
|
329
|
+
# The second reserves its opening range and waits for that permit before the first is throttled.
|
|
330
|
+
waiting = Thread.new { remote_file(store).download_content(sinks[1], transfer: job) }
|
|
331
|
+
wait_until { job.held_bytes == 131_072 && waiting.status == "sleep" }
|
|
332
|
+
answer << :go
|
|
333
|
+
wait_until { job.diagnostics[:throttled].any? }
|
|
334
|
+
# The third, through a client of its own and so another controller, waits that second out as well.
|
|
335
|
+
through_client = Thread.new do
|
|
336
|
+
Thread.current[:files_api_client_download_client] = own_client
|
|
337
|
+
remote_file(store).download_content(sinks[2], transfer: job)
|
|
338
|
+
end
|
|
339
|
+
[ owned, waiting, through_client ].each(&:join)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
expect(sinks.map(&:string)).to eq([ payload, payload, payload ])
|
|
343
|
+
expect(arrivals.size).to be > 3
|
|
344
|
+
expect(arrivals.drop(1).map { |at| at - arrivals.first }).to all(be >= 0.9)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "ends at once, when cancelled, a download's wait for an origin another's throttle holds back" do
|
|
348
|
+
store = storage_server(answer: ->(_request, _default) { TransferServer.response("503 Slow Down", { "Retry-After" => "60", "Content-Length" => 0 }) })
|
|
349
|
+
control = Files::Transfer::Control.new
|
|
350
|
+
started = nil
|
|
351
|
+
|
|
352
|
+
Files::Transfer::Job.open(**transfer, max_files: 2, control: control) do |job|
|
|
353
|
+
download = lambda do
|
|
354
|
+
Thread.new do
|
|
355
|
+
remote_file(store).download_content(StringIO.new("".b), transfer: job)
|
|
356
|
+
rescue StandardError => e
|
|
357
|
+
e
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
throttled = download.call
|
|
361
|
+
wait_until { job.diagnostics[:throttled].any? }
|
|
362
|
+
waiting = download.call
|
|
363
|
+
sleep 0.3 # the second is waiting out the first one's minute
|
|
364
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
365
|
+
control.cancel
|
|
366
|
+
|
|
367
|
+
expect(throttled.value).to be_a(Files::APIError).and(have_attributes(http_status: 503))
|
|
368
|
+
expect(waiting.value).to be_a(Files::Transfer::CancelledError)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 5
|
|
372
|
+
expect(store.requests.size).to eq(1)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "keeps the opening requests of a job's downloads within its request limit" do
|
|
376
|
+
small = payload.byteslice(0, 1000)
|
|
377
|
+
store = storage_server(small, answer: lambda { |_request, default|
|
|
378
|
+
sleep 0.3
|
|
379
|
+
default
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
sinks = Array.new(2) { StringIO.new("".b) }
|
|
383
|
+
|
|
384
|
+
Files::Transfer::Job.open(concurrency: 1, max_files: 2, buffer_bytes: 262_144) do |job|
|
|
385
|
+
sinks.map { |io| Thread.new { remote_file(store).download_content(io, transfer: job) } }.each(&:join)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
expect(sinks.map(&:string)).to eq([ small, small ])
|
|
389
|
+
expect(store.requests.size).to eq(2)
|
|
390
|
+
expect(store.peak_in_flight).to eq(1)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
it "counts a range as on the network until its whole body has arrived, so a body slower than its headers is judged" do
|
|
394
|
+
content = Random.new(5).bytes(20 * 65_536)
|
|
395
|
+
store = storage_server(content, answer: lambda { |request, default|
|
|
396
|
+
next default unless request.headers["if-match"]
|
|
397
|
+
|
|
398
|
+
head, body = default.split("\r\n\r\n", 2)
|
|
399
|
+
[ "#{head}\r\n\r\n", -> { sleep 0.05 }, body ]
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
Files::Transfer::Job.open(**transfer, max_concurrency: 2) do |job|
|
|
404
|
+
remote_file(store).download_content(sink, transfer: job)
|
|
405
|
+
|
|
406
|
+
# Nineteen ranges two at a time, each body held 50 ms after its headers: one full window, with both on the
|
|
407
|
+
# network nearly all of it.
|
|
408
|
+
controller = job.diagnostics[:controllers].first
|
|
409
|
+
expect(controller.values_at(:judged_windows, :ineligible_windows)).to eq([ 1, 0 ])
|
|
410
|
+
end
|
|
411
|
+
expect(sink.string).to eq(content)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
it "renews an expired download URL with the context the download began with, whatever changed since" do
|
|
415
|
+
store = storage_server
|
|
416
|
+
lookups = []
|
|
417
|
+
api = TransferServer.new(concurrent: true) do |request|
|
|
418
|
+
first = lookups.empty?
|
|
419
|
+
lookups << request
|
|
420
|
+
if first
|
|
421
|
+
Files.api_key = "changed-api-key"
|
|
422
|
+
Files.session_id = "changed-session"
|
|
423
|
+
Files.base_url = "http://127.0.0.1:1"
|
|
424
|
+
Files.workspace_id = 9
|
|
425
|
+
end
|
|
426
|
+
uri = store.url(first ? "/objects/report.bin?X-Amz-Date=20000101T000000Z&X-Amz-Expires=60" : "/objects/report.bin")
|
|
427
|
+
body = JSON.generate(path: "report.bin", type: "file", download_uri: uri)
|
|
428
|
+
TransferServer.response("200 OK", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
|
|
429
|
+
end
|
|
430
|
+
servers << api
|
|
431
|
+
workspace = Files.workspace_id
|
|
432
|
+
Files.base_url = api.url("")
|
|
433
|
+
Files.workspace_id = 7
|
|
434
|
+
|
|
435
|
+
begin
|
|
436
|
+
described_class.new(path: "report.bin").download_content(sink, transfer: transfer)
|
|
437
|
+
ensure
|
|
438
|
+
Files.workspace_id = workspace
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
expect(sink.string).to eq(payload)
|
|
442
|
+
expect(lookups.size).to eq(2)
|
|
443
|
+
expect(lookups.map { |request| request.headers.values_at("x-filesapi-auth", "x-filesapi-key", "x-files-workspace-id") }.uniq)
|
|
444
|
+
.to eq([ [ "fixture-session", nil, "7" ] ])
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
it "sends a range a worker takes up after a renewal to the origin it was admitted for, never past the new origin's throttle nor holding the old one back for it" do
|
|
448
|
+
Files.max_network_retries = 1
|
|
449
|
+
expiring_at = Time.now + 1.5
|
|
450
|
+
# The first origin answers a range after the opening one only once its URL is within the renewal margin, so the
|
|
451
|
+
# download renews it, on to the second origin, before admitting another.
|
|
452
|
+
origin_a = storage_server(answer: lambda { |request, default|
|
|
453
|
+
sleep 0.01 while !request.headers["range"].start_with?("bytes=0-") && Time.now <= expiring_at + 0.1
|
|
454
|
+
default
|
|
455
|
+
}
|
|
456
|
+
)
|
|
457
|
+
lock = Mutex.new
|
|
458
|
+
arrivals = []
|
|
459
|
+
# The second throttles its first request for a second, and any request that comes before that second is nearly
|
|
460
|
+
# over.
|
|
461
|
+
origin_b = storage_server(answer: lambda { |_request, default|
|
|
462
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
463
|
+
early = lock.synchronize { (arrivals << now).size == 1 || now - arrivals.first < 0.9 }
|
|
464
|
+
early ? TransferServer.response("503 Slow Down", { "Retry-After" => "1", "Content-Length" => 0 }) : default
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
api = api_server(origin_b)
|
|
468
|
+
record = described_class.new(path: "report.bin", download_uri: origin_a.url("/objects/report.bin?se=#{(expiring_at + 5).utc.iso8601(3)}"))
|
|
469
|
+
release = Queue.new
|
|
470
|
+
held = []
|
|
471
|
+
throttled = nil
|
|
472
|
+
|
|
473
|
+
Files::Transfer::Job.open(**transfer, concurrency: 2) do |job|
|
|
474
|
+
# The first range admitted, to the first origin, reaches its worker only once the test lets it: after the URL
|
|
475
|
+
# was renewed and the second origin's throttle holds it back.
|
|
476
|
+
gated = false
|
|
477
|
+
job.define_singleton_method(:submit) do |&work|
|
|
478
|
+
next super(&work) if gated
|
|
479
|
+
|
|
480
|
+
gated = true
|
|
481
|
+
super() do |connections|
|
|
482
|
+
release.pop
|
|
483
|
+
work.call(connections)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
job.define_singleton_method(:hold) do |url, seconds|
|
|
487
|
+
held << URI(url).port
|
|
488
|
+
super(url, seconds)
|
|
489
|
+
end
|
|
490
|
+
download = Thread.new { record.download_content(sink, transfer: job) }
|
|
491
|
+
begin
|
|
492
|
+
wait_until { job.diagnostics[:throttled].any? }
|
|
493
|
+
throttled = job.diagnostics[:throttled].keys
|
|
494
|
+
ensure
|
|
495
|
+
release << :go
|
|
496
|
+
end
|
|
497
|
+
download.join
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
expect(sink.string).to eq(payload)
|
|
501
|
+
expect(api.requests.size).to eq(1)
|
|
502
|
+
expect(throttled).to eq([ origin_b.url("") ])
|
|
503
|
+
expect(ranges(origin_a)).to include("bytes=65536-131071")
|
|
504
|
+
expect(ranges(origin_b)).not_to include("bytes=65536-131071")
|
|
505
|
+
expect(held.uniq).to eq([ URI(origin_b.url("")).port ])
|
|
506
|
+
expect(arrivals.drop(1).map { |at| at - arrivals.first }).to all(be >= 0.9)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
it "renews and sends again a range storage refused for its expired URL while the renewal it overlaps is unpublished, writing each range once" do
|
|
510
|
+
Files.max_network_retries = 1
|
|
511
|
+
expiring_at = Time.now + 1.5
|
|
512
|
+
refused = []
|
|
513
|
+
# The first origin answers the opening range at once, and a later range that arrives before its URL is within the
|
|
514
|
+
# renewal margin once it is. A range that arrives after that is refused 403, as storage refuses an expired URL.
|
|
515
|
+
origin_a = storage_server(answer: lambda { |request, default|
|
|
516
|
+
range = request.headers["range"]
|
|
517
|
+
next default if range.start_with?("bytes=0-")
|
|
518
|
+
|
|
519
|
+
if Time.now > expiring_at
|
|
520
|
+
refused << range
|
|
521
|
+
next TransferServer.response("403 Forbidden", { "Content-Length" => 0 })
|
|
522
|
+
end
|
|
523
|
+
sleep 0.01 while Time.now <= expiring_at + 0.1
|
|
524
|
+
default
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
origin_b = storage_server
|
|
528
|
+
api = api_server(origin_b)
|
|
529
|
+
record = described_class.new(path: "report.bin", download_uri: origin_a.url("/objects/report.bin?se=#{(expiring_at + 5).utc.iso8601(3)}"))
|
|
530
|
+
writes = []
|
|
531
|
+
recorder = StringIO.new("".b)
|
|
532
|
+
recorder.define_singleton_method(:<<) do |data|
|
|
533
|
+
writes << data.bytesize
|
|
534
|
+
super(data)
|
|
535
|
+
end
|
|
536
|
+
release = Queue.new
|
|
537
|
+
at_publication = Queue.new
|
|
538
|
+
publish = Queue.new
|
|
539
|
+
renewing = false
|
|
540
|
+
expiry_known = false
|
|
541
|
+
paused = false
|
|
542
|
+
stopped = nil
|
|
543
|
+
# The renewal stops on its first line after it has the replacement URL's expiry, before it publishes the
|
|
544
|
+
# replacement, until the range admitted before it has had storage's 403 for its expired URL and settled it. Judged
|
|
545
|
+
# by the expiry of the URL being published beside the generation of the one still published, that 403 is refused.
|
|
546
|
+
expiry = TracePoint.new(:return) { expiry_known = true if renewing }
|
|
547
|
+
step = TracePoint.new(:line) do
|
|
548
|
+
renewing = true
|
|
549
|
+
next unless expiry_known && !paused
|
|
550
|
+
|
|
551
|
+
paused = true
|
|
552
|
+
at_publication << :renewing
|
|
553
|
+
publish.pop
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
Files::Transfer::Job.open(**transfer, concurrency: 2) do |job|
|
|
557
|
+
# The first range admitted, to the first origin, reaches its worker only once the renewal has stopped.
|
|
558
|
+
gated = false
|
|
559
|
+
job.define_singleton_method(:submit) do |&work|
|
|
560
|
+
next super(&work) if gated
|
|
561
|
+
|
|
562
|
+
gated = true
|
|
563
|
+
super() do |connections|
|
|
564
|
+
release.pop
|
|
565
|
+
work.call(connections)
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
expiry.enable(target: Files::Transfer::StorageRetry.method(:expires_at))
|
|
569
|
+
step.enable(target: Files::Transfer::DownloadCoordinator.instance_method(:renew_url))
|
|
570
|
+
begin
|
|
571
|
+
download = Thread.new { record.download_content(recorder, transfer: job) }
|
|
572
|
+
# At most five seconds for the renewal to stop, without Queue#pop's timeout, which Ruby before 3.2 lacks: its
|
|
573
|
+
# signal is taken only once it has arrived, and stopped stays nil when it never does.
|
|
574
|
+
wait_until { !at_publication.empty? }
|
|
575
|
+
stopped = at_publication.pop(true) unless at_publication.empty?
|
|
576
|
+
release << :go
|
|
577
|
+
wait_until { origin_a.requests.size == 3 && job.diagnostics[:requests].zero? }
|
|
578
|
+
publish << :go
|
|
579
|
+
download.join
|
|
580
|
+
ensure
|
|
581
|
+
release << :go
|
|
582
|
+
publish << :go
|
|
583
|
+
step.disable
|
|
584
|
+
expiry.disable
|
|
585
|
+
end
|
|
586
|
+
expect(job.held_bytes).to eq(0)
|
|
587
|
+
expect(job.diagnostics.values_at(:accepted_bytes, :retries)).to eq([ payload.bytesize, 1 ])
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
expect(stopped).to eq(:renewing)
|
|
591
|
+
expect(refused).to eq([ "bytes=65536-131071" ])
|
|
592
|
+
expect(api.requests.size).to eq(1)
|
|
593
|
+
expect(ranges(origin_b).count("bytes=65536-131071")).to eq(1)
|
|
594
|
+
expect(recorder.string).to eq(payload)
|
|
595
|
+
expect(writes).to eq(([ 65_536 ] * 10) + [ 1000 ])
|
|
596
|
+
expect(recorder).not_to be_closed
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
describe "without a bound version" do
|
|
601
|
+
it "streams the whole response once when the opening range has a weak ETag" do
|
|
602
|
+
store = storage_server(etag: "W/\"v1\"")
|
|
603
|
+
|
|
604
|
+
remote_file(store).download_content(sink, transfer: transfer)
|
|
605
|
+
|
|
606
|
+
expect(sink.string).to eq(payload)
|
|
607
|
+
expect(ranges(store)).to eq([ "bytes=0-65535", nil ])
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
it "streams a whole response storage sent for the opening range" do
|
|
611
|
+
store = storage_server(answer: ->(_request, _default) { TransferServer.response("200 OK", { "Content-Length" => payload.bytesize }, payload) })
|
|
612
|
+
|
|
613
|
+
remote_file(store).download_content(sink, transfer: transfer)
|
|
614
|
+
|
|
615
|
+
expect(sink.string).to eq(payload)
|
|
616
|
+
expect(store.requests.size).to eq(1)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
it "writes nothing for an empty file" do
|
|
620
|
+
store = storage_server(answer: ->(_request, _default) { TransferServer.response("416 Range Not Satisfiable", { "Content-Range" => "bytes */0", "Content-Length" => 0 }) })
|
|
621
|
+
|
|
622
|
+
remote_file(store).download_content(sink, transfer: transfer)
|
|
623
|
+
|
|
624
|
+
expect(sink.string).to be_empty
|
|
625
|
+
expect(sink).not_to be_closed
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
it "starts a local file over from its first byte when a whole response ends early" do
|
|
629
|
+
Files.max_network_retries = 1
|
|
630
|
+
lock = Mutex.new
|
|
631
|
+
sent = 0
|
|
632
|
+
store = storage_server(answer: lambda { |_request, _default|
|
|
633
|
+
first = lock.synchronize { (sent += 1) == 1 }
|
|
634
|
+
TransferServer.response("200 OK", { "Content-Length" => payload.bytesize }, first ? payload.byteslice(0, 4096) : payload)
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
Dir.mktmpdir do |dir|
|
|
639
|
+
local_path = File.join(dir, "report.bin")
|
|
640
|
+
remote_file(store).download_file(local_path, transfer: transfer)
|
|
641
|
+
|
|
642
|
+
expect(File.binread(local_path)).to eq(payload)
|
|
643
|
+
end
|
|
644
|
+
expect(store.requests.size).to eq(2)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
it "raises instead of writing bytes twice into an IO that cannot start over" do
|
|
648
|
+
Files.max_network_retries = 1
|
|
649
|
+
store = storage_server(answer: ->(_request, _default) { TransferServer.response("200 OK", { "Content-Length" => payload.bytesize }, payload.byteslice(0, 4096)) })
|
|
650
|
+
written = "".b
|
|
651
|
+
append_only = Object.new
|
|
652
|
+
append_only.define_singleton_method(:<<) { |chunk| written << chunk }
|
|
653
|
+
|
|
654
|
+
expect { remote_file(store).download_content(append_only, transfer: transfer) }
|
|
655
|
+
.to raise_error(EOFError, "Transfer response ended after 4096 of #{payload.bytesize} bytes")
|
|
656
|
+
expect(written).to eq(payload.byteslice(0, 4096))
|
|
657
|
+
expect(store.requests.size).to eq(1)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
it "keeps what a whole response wrote when it is cancelled" do
|
|
661
|
+
store = storage_server(etag: "W/\"v1\"")
|
|
662
|
+
control = Files::Transfer::Control.new
|
|
663
|
+
cancel = ->(report) { control.cancel if report[:written_bytes] > 65_536 }
|
|
664
|
+
|
|
665
|
+
Dir.mktmpdir do |dir|
|
|
666
|
+
local_path = File.join(dir, "report.bin")
|
|
667
|
+
expect { remote_file(store).download_file(local_path, progress: cancel, transfer: transfer.merge(control: control)) }
|
|
668
|
+
.to raise_error(Files::Transfer::CancelledError)
|
|
669
|
+
|
|
670
|
+
kept = File.binread(local_path)
|
|
671
|
+
expect(kept.bytesize).to be > 0
|
|
672
|
+
expect(kept).to eq(payload.byteslice(0, kept.bytesize))
|
|
673
|
+
end
|
|
674
|
+
expect(ranges(store)).to eq([ "bytes=0-65535", nil ])
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
it "calls progress for a whole response in the ApiClient#request scope it was called from, through a download_client as over the job's own connections" do
|
|
678
|
+
whole = storage_server(etag: "W/\"v1\"")
|
|
679
|
+
scope = Files::ApiClient.new(Files::ApiClient.build_default_conn, workspace_id: 42)
|
|
680
|
+
seen = []
|
|
681
|
+
download = lambda do
|
|
682
|
+
io = StringIO.new("".b)
|
|
683
|
+
scope.request { remote_file(whole).download_content(io, progress: ->(_report) { seen << Files::ApiClient.active_client }, transfer: transfer) }
|
|
684
|
+
expect(io.string).to eq(payload)
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
download.call
|
|
688
|
+
Thread.current[:files_api_client_download_client] = own_client
|
|
689
|
+
begin
|
|
690
|
+
download.call
|
|
691
|
+
ensure
|
|
692
|
+
Thread.current[:files_api_client_download_client] = nil
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
expect(seen.size).to be > 2
|
|
696
|
+
expect(seen.uniq).to eq([ scope ])
|
|
697
|
+
expect(ranges(whole)).to eq([ "bytes=0-65535", nil, "bytes=0-65535", nil ])
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
describe "refusals" do
|
|
702
|
+
it "refuses a response from the Files.com web app before writing any of it, and closes the IO" do
|
|
703
|
+
page = "<html>Sign in</html>"
|
|
704
|
+
store = storage_server(answer: lambda { |_request, _default|
|
|
705
|
+
TransferServer.response("206 Partial Content", { "X-Files-Frontend-App" => "true", "ETag" => "\"v1\"", "Content-Range" => "bytes 0-19/20", "Content-Length" => 20 }, page)
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
expect { remote_file(store).download_content(sink, transfer: transfer) }.to raise_error(Files::Transfer::FrontendResponseError)
|
|
710
|
+
expect(sink.string).to be_empty
|
|
711
|
+
expect(sink).to be_closed
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
it "refuses a range combined with transfer" do
|
|
715
|
+
expect { remote_file(storage_server).download_content(sink, range: [ 0, 9 ], transfer: transfer) }.to raise_error(Files::InvalidParameterError)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
it "raises a cancellation that came before its opening range was refused, leaving the IO open" do
|
|
719
|
+
control = Files::Transfer::Control.new
|
|
720
|
+
store = storage_server(answer: lambda { |_request, _default|
|
|
721
|
+
control.cancel
|
|
722
|
+
TransferServer.response("403 Forbidden", { "Content-Length" => 0 })
|
|
723
|
+
}
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
expect { remote_file(store).download_content(sink, transfer: transfer.merge(control: control)) }.to raise_error(Files::Transfer::CancelledError)
|
|
727
|
+
expect(sink).not_to be_closed
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
it "raises a refusal that came before a cancellation, made here as the refusal closes the IO" do
|
|
731
|
+
control = Files::Transfer::Control.new
|
|
732
|
+
closing = Class.new(StringIO) do
|
|
733
|
+
attr_accessor :on_close
|
|
734
|
+
|
|
735
|
+
def close
|
|
736
|
+
super
|
|
737
|
+
on_close&.call
|
|
738
|
+
end
|
|
739
|
+
end.new("".b)
|
|
740
|
+
closing.on_close = -> { control.cancel }
|
|
741
|
+
store = storage_server(answer: ->(_request, _default) { TransferServer.response("403 Forbidden", { "Content-Length" => 0 }) })
|
|
742
|
+
|
|
743
|
+
expect { remote_file(store).download_content(closing, transfer: transfer.merge(control: control)) }
|
|
744
|
+
.to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(403) }
|
|
745
|
+
expect(closing).to be_closed
|
|
746
|
+
expect(control).to be_cancelled
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
it "raises a cancellation that came before the API refused to give the download URL, leaving the IO open" do
|
|
750
|
+
control = Files::Transfer::Control.new
|
|
751
|
+
api = TransferServer.new(concurrent: true) do |_request|
|
|
752
|
+
control.cancel
|
|
753
|
+
body = { error: "Not Found", 'http-code': 404, type: "not-found" }.to_json
|
|
754
|
+
TransferServer.response("404 Not Found", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
|
|
755
|
+
end
|
|
756
|
+
servers << api
|
|
757
|
+
Files.base_url = api.url("")
|
|
758
|
+
|
|
759
|
+
expect { described_class.new(path: "report.bin").download_content(sink, transfer: transfer.merge(control: control)) }
|
|
760
|
+
.to raise_error(Files::Transfer::CancelledError)
|
|
761
|
+
expect(sink).not_to be_closed
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
it "raises a cancellation its progress callback made on the last write, not a success" do
|
|
765
|
+
small = payload.byteslice(0, 1000)
|
|
766
|
+
control = Files::Transfer::Control.new
|
|
767
|
+
store = storage_server(small)
|
|
768
|
+
|
|
769
|
+
expect { remote_file(store).download_content(sink, progress: ->(_report) { control.cancel }, transfer: transfer.merge(control: control)) }
|
|
770
|
+
.to raise_error(Files::Transfer::CancelledError)
|
|
771
|
+
expect(sink.string).to eq(small)
|
|
772
|
+
expect(sink).not_to be_closed
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
describe "pause and resume" do
|
|
777
|
+
# Storage that pauses the returned control when the range after the opening one arrives, then answers it, or
|
|
778
|
+
# passes it to answer.
|
|
779
|
+
def pause_after_first_range(answer = nil)
|
|
780
|
+
control = Files::Transfer::Control.new
|
|
781
|
+
store = storage_server(answer: lambda { |request, default|
|
|
782
|
+
control.pause if request.headers["range"] == "bytes=65536-131071"
|
|
783
|
+
answer ? answer.call(request, default) : default
|
|
784
|
+
}
|
|
785
|
+
)
|
|
786
|
+
[ store, control ]
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
it "pauses with a checkpoint that resumes the same local file from where it stopped, with If-Match, and pauses a resume with a checkpoint of all the bytes written" do
|
|
790
|
+
resume_control = Files::Transfer::Control.new
|
|
791
|
+
# The first resume is paused in turn when the range after its own opening one arrives.
|
|
792
|
+
store, control = pause_after_first_range(lambda { |request, default|
|
|
793
|
+
resume_control.pause if request.headers["range"] == "bytes=196608-262143"
|
|
794
|
+
default
|
|
795
|
+
}
|
|
796
|
+
)
|
|
797
|
+
api_server(store)
|
|
798
|
+
|
|
799
|
+
Dir.mktmpdir do |dir|
|
|
800
|
+
local_path = File.join(dir, "report.bin")
|
|
801
|
+
paused = nil
|
|
802
|
+
expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
803
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
804
|
+
checkpoint = paused.checkpoint
|
|
805
|
+
expect([ checkpoint.direction, checkpoint.completed_bytes, checkpoint.total_bytes, checkpoint.version ]).to eq([ :download, 131_072, payload.bytesize, "\"v1\"" ])
|
|
806
|
+
expect(checkpoint.inspect).not_to include("/objects/")
|
|
807
|
+
expect(File.binread(local_path)).to eq(payload.byteslice(0, 131_072))
|
|
808
|
+
expect(checkpoint.state[:digest]).to eq(Digest::SHA256.hexdigest(payload.byteslice(0, 131_072)))
|
|
809
|
+
resumed_from = store.requests.size
|
|
810
|
+
|
|
811
|
+
# The resume checks the bytes written against that digest, and its own checkpoint's digest goes on from it over
|
|
812
|
+
# the bytes the resume wrote.
|
|
813
|
+
paused_again = nil
|
|
814
|
+
expect { described_class.resume_download(checkpoint, nil, {}, transfer: transfer.merge(concurrency: 1, control: resume_control)) }
|
|
815
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused_again = error }
|
|
816
|
+
checkpoint_again = paused_again.checkpoint
|
|
817
|
+
expect([ checkpoint_again.direction, checkpoint_again.completed_bytes, checkpoint_again.total_bytes, checkpoint_again.version ])
|
|
818
|
+
.to eq([ :download, 262_144, payload.bytesize, "\"v1\"" ])
|
|
819
|
+
expect(File.binread(local_path)).to eq(payload.byteslice(0, 262_144))
|
|
820
|
+
expect(checkpoint_again.state[:digest]).to eq(Digest::SHA256.hexdigest(payload.byteslice(0, 262_144)))
|
|
821
|
+
resumed_again_from = store.requests.size
|
|
822
|
+
|
|
823
|
+
described_class.resume_download(checkpoint_again, nil, {}, transfer: transfer)
|
|
824
|
+
|
|
825
|
+
expect(File.binread(local_path)).to eq(payload)
|
|
826
|
+
expect(store.requests.drop(resumed_from).map { |request| request.headers["if-match"] }.uniq).to eq([ "\"v1\"" ])
|
|
827
|
+
expect(store.requests.drop(resumed_from).first.headers["range"]).to eq("bytes=131072-196607")
|
|
828
|
+
expect(store.requests.drop(resumed_again_from).first.headers["range"]).to eq("bytes=262144-327679")
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
it "refuses a checkpoint whose local file was replaced, and stops when the remote version changed" do
|
|
833
|
+
changed = false
|
|
834
|
+
store, control = pause_after_first_range(lambda { |request, default|
|
|
835
|
+
changed && request.headers["if-match"] ? TransferServer.response("412 Precondition Failed", { "Content-Length" => 0 }) : default
|
|
836
|
+
}
|
|
837
|
+
)
|
|
838
|
+
api_server(store)
|
|
839
|
+
|
|
840
|
+
Dir.mktmpdir do |dir|
|
|
841
|
+
local_path = File.join(dir, "report.bin")
|
|
842
|
+
paused = nil
|
|
843
|
+
expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
844
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
845
|
+
|
|
846
|
+
File.rename(local_path, "#{local_path}.moved")
|
|
847
|
+
File.binwrite(local_path, payload.byteslice(0, 131_072))
|
|
848
|
+
expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
|
|
849
|
+
|
|
850
|
+
File.rename("#{local_path}.moved", local_path)
|
|
851
|
+
changed = true
|
|
852
|
+
expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
it "resumes into the same IO only" do
|
|
857
|
+
store, control = pause_after_first_range
|
|
858
|
+
api_server(store)
|
|
859
|
+
paused = nil
|
|
860
|
+
|
|
861
|
+
expect { remote_file(store).download_content(sink, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
862
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
863
|
+
expect(sink).not_to be_closed
|
|
864
|
+
expect { described_class.resume_download(paused.checkpoint, StringIO.new("".b), {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
|
|
865
|
+
|
|
866
|
+
described_class.resume_download(paused.checkpoint, sink, {}, transfer: transfer)
|
|
867
|
+
expect(sink.string).to eq(payload)
|
|
868
|
+
expect { described_class.resume_download(paused.checkpoint, sink, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError, /already been used/)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
it "refuses to resume over bytes changed in the same local file or IO, changing nothing" do
|
|
872
|
+
store, control = pause_after_first_range
|
|
873
|
+
api_server(store)
|
|
874
|
+
|
|
875
|
+
Dir.mktmpdir do |dir|
|
|
876
|
+
local_path = File.join(dir, "report.bin")
|
|
877
|
+
paused = nil
|
|
878
|
+
expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
879
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
880
|
+
edited = payload.byteslice(0, 131_072).dup
|
|
881
|
+
edited.setbyte(10, edited.getbyte(10) ^ 0xff)
|
|
882
|
+
File.open(local_path, "r+b") { |file| file.write(edited) }
|
|
883
|
+
resumed_from = store.requests.size
|
|
884
|
+
|
|
885
|
+
expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
|
|
886
|
+
expect(File.binread(local_path)).to eq(edited)
|
|
887
|
+
expect(store.requests.size).to eq(resumed_from)
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
store, control = pause_after_first_range
|
|
891
|
+
api_server(store)
|
|
892
|
+
paused = nil
|
|
893
|
+
expect { remote_file(store).download_content(sink, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
894
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
895
|
+
sink.string.setbyte(0, sink.string.getbyte(0) ^ 0xff)
|
|
896
|
+
edited = sink.string.dup
|
|
897
|
+
|
|
898
|
+
expect { described_class.resume_download(paused.checkpoint, sink, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
|
|
899
|
+
expect(sink.string).to eq(edited)
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
it "raises a pause made while a resume reads its bytes back, not the change it finds after, leaving the checkpoint unspent" do
|
|
903
|
+
store, control = pause_after_first_range
|
|
904
|
+
api_server(store)
|
|
905
|
+
io = Tempfile.new("barrier").tap(&:binmode)
|
|
906
|
+
reading = Queue.new
|
|
907
|
+
proceed = Queue.new
|
|
908
|
+
barrier = nil
|
|
909
|
+
recorder = SimpleDelegator.new(io)
|
|
910
|
+
recorder.define_singleton_method(:pread) do |length, offset|
|
|
911
|
+
if barrier
|
|
912
|
+
gate = barrier
|
|
913
|
+
barrier = nil
|
|
914
|
+
gate.call
|
|
915
|
+
end
|
|
916
|
+
io.pread(length, offset)
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
paused = nil
|
|
920
|
+
expect { remote_file(store).download_content(recorder, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
921
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
922
|
+
io.pwrite((io.pread(1, 0).ord ^ 0xff).chr, 0)
|
|
923
|
+
edited = io.pread(131_072, 0)
|
|
924
|
+
resumed_from = store.requests.size
|
|
925
|
+
|
|
926
|
+
# The resume's first read-back waits here; it is paused meanwhile, and then finds the changed byte.
|
|
927
|
+
barrier = lambda do
|
|
928
|
+
reading << :reading
|
|
929
|
+
proceed.pop
|
|
930
|
+
end
|
|
931
|
+
resume_control = Files::Transfer::Control.new
|
|
932
|
+
resume = Thread.new do
|
|
933
|
+
described_class.resume_download(paused.checkpoint, recorder, {}, transfer: transfer.merge(control: resume_control))
|
|
934
|
+
rescue StandardError => e
|
|
935
|
+
e
|
|
936
|
+
end
|
|
937
|
+
reading.pop
|
|
938
|
+
resume_control.pause
|
|
939
|
+
proceed << :go
|
|
940
|
+
|
|
941
|
+
error = resume.value
|
|
942
|
+
expect(error).to be_a(Files::Transfer::PausedError)
|
|
943
|
+
expect(error.checkpoint).to equal(paused.checkpoint)
|
|
944
|
+
expect(io.pread(131_072, 0)).to eq(edited)
|
|
945
|
+
expect(store.requests.size).to eq(resumed_from)
|
|
946
|
+
# Unspent: the next resume checks the bytes again, and refuses them as changed.
|
|
947
|
+
expect { described_class.resume_download(paused.checkpoint, recorder, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError, /changed/)
|
|
948
|
+
ensure
|
|
949
|
+
io&.close!
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
it "stops a resumed download that storage answers whole with another version, before truncating anything" do
|
|
953
|
+
other = false
|
|
954
|
+
store, control = pause_after_first_range(lambda { |_request, default|
|
|
955
|
+
other ? TransferServer.response("200 OK", { "ETag" => "\"v2\"", "Content-Length" => payload.bytesize }, payload.reverse) : default
|
|
956
|
+
}
|
|
957
|
+
)
|
|
958
|
+
api_server(store)
|
|
959
|
+
|
|
960
|
+
Dir.mktmpdir do |dir|
|
|
961
|
+
local_path = File.join(dir, "report.bin")
|
|
962
|
+
paused = nil
|
|
963
|
+
expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
964
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
965
|
+
File.open(local_path, "ab") { |file| file.write("written after the pause") }
|
|
966
|
+
other = true
|
|
967
|
+
|
|
968
|
+
expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
|
|
969
|
+
expect(File.binread(local_path)).to eq("#{payload.byteslice(0, 131_072)}written after the pause".b)
|
|
970
|
+
# That resume started, so its failure spent the checkpoint.
|
|
971
|
+
expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError, /already been used/)
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
it "reads back the bytes a Tempfile holds to check them before resuming into it" do
|
|
976
|
+
kept = Tempfile.new("kept").tap(&:binmode)
|
|
977
|
+
edited = Tempfile.new("edited").tap(&:binmode)
|
|
978
|
+
|
|
979
|
+
checkpoints = [ kept, edited ].map do |io|
|
|
980
|
+
store, control = pause_after_first_range
|
|
981
|
+
api_server(store)
|
|
982
|
+
paused = nil
|
|
983
|
+
expect { remote_file(store).download_content(io, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
984
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
985
|
+
paused.checkpoint
|
|
986
|
+
end
|
|
987
|
+
File.open(edited.path, "r+b") { |file| file.write("x") }
|
|
988
|
+
|
|
989
|
+
expect { described_class.resume_download(checkpoints[1], edited, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
|
|
990
|
+
described_class.resume_download(checkpoints[0], kept, {}, transfer: transfer)
|
|
991
|
+
kept.rewind
|
|
992
|
+
expect(kept.read).to eq(payload)
|
|
993
|
+
ensure
|
|
994
|
+
[ kept, edited ].each { |io| io&.close! }
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
it "gives no checkpoint for a bound download into an IO that cannot say where its bytes began" do
|
|
998
|
+
store, control = pause_after_first_range
|
|
999
|
+
written = "".b
|
|
1000
|
+
append_only = Object.new
|
|
1001
|
+
append_only.define_singleton_method(:<<) { |chunk| written << chunk }
|
|
1002
|
+
|
|
1003
|
+
expect { remote_file(store).download_content(append_only, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
1004
|
+
.to raise_error(Files::Transfer::PausedError) { |error| expect(error.checkpoint).to be_nil }
|
|
1005
|
+
expect(written).to eq(payload.byteslice(0, 131_072))
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
it "gives no checkpoint, and sends nothing, for a download paused while its local file opens" do
|
|
1009
|
+
store = storage_server
|
|
1010
|
+
control = Files::Transfer::Control.new
|
|
1011
|
+
# Opening the local file pauses the download and then fails: the pause came first, with nothing opened to resume.
|
|
1012
|
+
local_path = Object.new
|
|
1013
|
+
local_path.define_singleton_method(:to_path) do
|
|
1014
|
+
control.pause
|
|
1015
|
+
"/nonexistent/report.bin"
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
Files::Transfer::Job.open(**transfer, control: control) do |job|
|
|
1019
|
+
expect { remote_file(store).download_file(local_path, transfer: job) }
|
|
1020
|
+
.to raise_error(Files::Transfer::PausedError) { |error| expect(error.checkpoint).to be_nil }
|
|
1021
|
+
expect(job.diagnostics.values_at(:requests, :held_bytes)).to eq([ 0, 0 ])
|
|
1022
|
+
end
|
|
1023
|
+
expect(store.requests).to be_empty
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
it "has storage prove the version of a download paused with every byte written before dropping what follows them" do
|
|
1027
|
+
last = payload.bytesize - 1
|
|
1028
|
+
[ false, true ].each do |changed|
|
|
1029
|
+
other = false
|
|
1030
|
+
control = Files::Transfer::Control.new
|
|
1031
|
+
store = storage_server(answer: lambda { |request, default|
|
|
1032
|
+
control.pause if request.headers["range"] == "bytes=655360-#{last}"
|
|
1033
|
+
other && request.headers["if-match"] ? TransferServer.response("412 Precondition Failed", { "Content-Length" => 0 }) : default
|
|
1034
|
+
}
|
|
1035
|
+
)
|
|
1036
|
+
api_server(store)
|
|
1037
|
+
|
|
1038
|
+
Dir.mktmpdir do |dir|
|
|
1039
|
+
local_path = File.join(dir, "report.bin")
|
|
1040
|
+
paused = nil
|
|
1041
|
+
expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
|
|
1042
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1043
|
+
expect([ paused.checkpoint.completed_bytes, paused.checkpoint.total_bytes ]).to eq([ payload.bytesize, payload.bytesize ])
|
|
1044
|
+
File.open(local_path, "ab") { |file| file.write("written after the pause") }
|
|
1045
|
+
other = changed
|
|
1046
|
+
resumed_from = store.requests.size
|
|
1047
|
+
|
|
1048
|
+
if changed
|
|
1049
|
+
expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
|
|
1050
|
+
expect(File.binread(local_path)).to eq("#{payload}written after the pause".b)
|
|
1051
|
+
else
|
|
1052
|
+
described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer)
|
|
1053
|
+
expect(File.binread(local_path)).to eq(payload)
|
|
1054
|
+
end
|
|
1055
|
+
expect(store.requests.drop(resumed_from).map { |request| request.headers.values_at("range", "if-match") }).to eq([ [ "bytes=#{last}-#{last}", "\"v1\"" ] ])
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
it "reads more than 1 MiB of written bytes back in charged slices within a 64 KiB budget another download shares" do
|
|
1061
|
+
big = Random.new(11).bytes(1_048_576 + (4 * 16_384))
|
|
1062
|
+
control = Files::Transfer::Control.new
|
|
1063
|
+
store = storage_server(big, answer: lambda { |request, default|
|
|
1064
|
+
control.pause if request.headers["range"] == "bytes=1064960-1081343"
|
|
1065
|
+
default
|
|
1066
|
+
}
|
|
1067
|
+
)
|
|
1068
|
+
api_server(store)
|
|
1069
|
+
io = Tempfile.new("prefix").tap(&:binmode)
|
|
1070
|
+
reads = []
|
|
1071
|
+
recorder = SimpleDelegator.new(io)
|
|
1072
|
+
recorder.define_singleton_method(:pread) do |length, offset|
|
|
1073
|
+
reads << length
|
|
1074
|
+
io.pread(length, offset)
|
|
1075
|
+
end
|
|
1076
|
+
budget = { buffer_bytes: 65_536 }
|
|
1077
|
+
|
|
1078
|
+
paused = nil
|
|
1079
|
+
expect { remote_file(store).download_content(recorder, transfer: budget.merge(concurrency: 1, control: control)) }
|
|
1080
|
+
.to raise_error(Files::Transfer::PausedError) { |error| paused = error }
|
|
1081
|
+
expect(paused.checkpoint.completed_bytes).to eq(1_081_344)
|
|
1082
|
+
|
|
1083
|
+
small = payload.byteslice(0, 40_000)
|
|
1084
|
+
neighbour_sink = StringIO.new("".b)
|
|
1085
|
+
neighbour_store = storage_server(small)
|
|
1086
|
+
Files::Transfer::Job.open(**budget, max_files: 2) do |job|
|
|
1087
|
+
neighbour = Thread.new { remote_file(neighbour_store).download_content(neighbour_sink, transfer: job) }
|
|
1088
|
+
described_class.resume_download(paused.checkpoint, recorder, {}, transfer: job)
|
|
1089
|
+
neighbour.join
|
|
1090
|
+
expect(job.held_bytes).to eq(0)
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
expect(reads.sum).to eq(1_081_344)
|
|
1094
|
+
expect(reads.max).to eq(16_384)
|
|
1095
|
+
io.rewind
|
|
1096
|
+
expect(io.read).to eq(big)
|
|
1097
|
+
expect(neighbour_sink.string).to eq(small)
|
|
1098
|
+
ensure
|
|
1099
|
+
io&.close!
|
|
1100
|
+
end
|
|
1101
|
+
end
|
|
1102
|
+
end
|