files.com 1.1.813 → 1.1.814
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 +1 -1
- 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 +1 -1
- 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 +4703 -2415
- data/doc/top-level-namespace.html +1 -1
- data/docs/file.md +346 -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/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,906 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Files
|
|
6
|
+
module Transfer
|
|
7
|
+
# Downloads one remote file through a Job, in ranges fetched concurrently when storage binds a version.
|
|
8
|
+
#
|
|
9
|
+
# Its API requests, the download URL's lookup and its renewals, use the context the caller's thread fixed from the
|
|
10
|
+
# record's options as the download began (Coordinator.api_context), carried on as a lookup moves to a regional
|
|
11
|
+
# origin. Its content requests go through ApiClient.download_client as the caller's thread resolves it when the
|
|
12
|
+
# download begins: over the job's own connections when that client is the SDK's own with its default connection
|
|
13
|
+
# unchanged, otherwise through the client itself, one request at a time, its responses streamed (see
|
|
14
|
+
# ClientTransport); a client whose adapter cannot stream is refused before anything is requested or written. On the
|
|
15
|
+
# caller's thread it first asks for the opening range (8 MiB, or a quarter of buffer_bytes when that is less) with
|
|
16
|
+
# Accept-Encoding: identity. A 206 with a strong ETag and an exact Content-Range and Content-Length binds the
|
|
17
|
+
# download to that version: every later range is requested with If-Match and must come back as a 206 with the
|
|
18
|
+
# exact Content-Range, total, length and ETag, and a 412, or a whole response with any other ETag, stops it with
|
|
19
|
+
# VersionChangedError before anything of it is written. Ranges complete in any order, each read straight into one
|
|
20
|
+
# String of its length, wait in memory within buffer_bytes, and are written to the destination in order on the
|
|
21
|
+
# caller's thread, which progress, when given, is then called on. A range is on the network, for its controller,
|
|
22
|
+
# until its whole body has arrived and been checked. Every request, the opening one and a proof included, waits
|
|
23
|
+
# while a throttle's Retry-After holds its destination back for the job (Holds), and publishes one it gets before
|
|
24
|
+
# its request permit is given back. A range goes to the URL it was admitted for, even once that has been renewed,
|
|
25
|
+
# and a 403 for it is judged by that URL's own expiry.
|
|
26
|
+
#
|
|
27
|
+
# Without a strong ETag, storage's whole response is streamed instead, as one request. A retry of it starts again
|
|
28
|
+
# from the first byte: the destination, a local file or an IO that can seek back, is truncated to where it started
|
|
29
|
+
# only as the retry's first bytes are written; any other IO gets the failure instead of the same bytes twice. A
|
|
30
|
+
# cancellation or pause keeps what was written. Bytes are written exactly as stored, never decompressed.
|
|
31
|
+
#
|
|
32
|
+
# A resumed download first checks, before it sends or changes anything, that its destination is the same one and
|
|
33
|
+
# still holds exactly the bytes it wrote, by reading them back, a slice at a time charged to buffer_bytes, and
|
|
34
|
+
# comparing their SHA-256 digest; a destination that cannot give them back refuses the resume. Anything after those
|
|
35
|
+
# bytes stays until storage's response has proved its version: until the first new bytes are written, or, when every
|
|
36
|
+
# byte was written before the pause, until storage answers for the file's last byte with that version.
|
|
37
|
+
#
|
|
38
|
+
# A response carrying X-Files-Frontend-App came from the Files.com web app, not storage, and stops the download
|
|
39
|
+
# with FrontendResponseError before any of it is written. A refused transfer or a failed write closes a caller's
|
|
40
|
+
# IO, as without transfer:; other failures leave it open, holding what was written.
|
|
41
|
+
class DownloadCoordinator < Coordinator
|
|
42
|
+
RENEWALS = 2
|
|
43
|
+
MISMATCH = "Transfer response did not match the request"
|
|
44
|
+
EXPIRED = "The download URL the API returned has expired, and renewing it did not give one still valid"
|
|
45
|
+
# The most of a resumed destination's bytes read back at once to check them; never more than a range.
|
|
46
|
+
VERIFY_CHUNK_BYTES = 1024 * 1024
|
|
47
|
+
ControlStop = Class.new(StandardError)
|
|
48
|
+
|
|
49
|
+
Piece = Struct.new(:number, :offset, :bytesize, :reservation, :attempts, :renewals, :ready_at, :data, :generation, keyword_init: true)
|
|
50
|
+
|
|
51
|
+
# A SHA-256 digest of the next bytes of io, read chunk bytes at a time, into one buffer when io takes one, or false
|
|
52
|
+
# when io ends before them.
|
|
53
|
+
def self.digest_of(io, bytes, chunk)
|
|
54
|
+
digest = OpenSSL::Digest.new('SHA256')
|
|
55
|
+
buffer = String.new(capacity: chunk, encoding: Encoding::BINARY) if takes_buffer?(io)
|
|
56
|
+
remaining = bytes
|
|
57
|
+
while remaining.positive?
|
|
58
|
+
wanted = [ remaining, chunk ].min
|
|
59
|
+
data = buffer ? io.read(wanted, buffer) : io.read(wanted)
|
|
60
|
+
return false if data.nil? || data.empty?
|
|
61
|
+
|
|
62
|
+
digest.update(data)
|
|
63
|
+
remaining -= data.bytesize
|
|
64
|
+
end
|
|
65
|
+
digest
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether io's read and pread take a buffer to read into, as an IO's, a StringIO's and a Tempfile's do.
|
|
69
|
+
def self.takes_buffer?(io)
|
|
70
|
+
io.is_a?(::IO) || io.is_a?(StringIO) || (defined?(::Tempfile) && io.is_a?(::Tempfile))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The local file download_file opened, or reopened to resume. It can always start over. A restart or resume only
|
|
74
|
+
# positions it: what they drop is truncated as the next bytes are written, or as the download completes.
|
|
75
|
+
class FileSink
|
|
76
|
+
attr_reader :path
|
|
77
|
+
|
|
78
|
+
def initialize(file, path)
|
|
79
|
+
@file = file
|
|
80
|
+
@path = path
|
|
81
|
+
@pending = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write(data)
|
|
85
|
+
settle!
|
|
86
|
+
@file.write(data)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def restartable?
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def restart!
|
|
94
|
+
@pending = 0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def verifiable?
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def identity
|
|
102
|
+
Checkpoint.identity(@file.stat, contents: false)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def holds?(bytes)
|
|
106
|
+
@file.size >= bytes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A digest of the file's first bytes, read chunk bytes at a time, or false when it is shorter.
|
|
110
|
+
def prefix_digest(bytes, chunk)
|
|
111
|
+
@file.rewind
|
|
112
|
+
DownloadCoordinator.digest_of(@file, bytes, chunk)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Writing continues after the first bytes; what follows them is dropped only as the next bytes are written.
|
|
116
|
+
def resume_at!(bytes)
|
|
117
|
+
@file.seek(bytes)
|
|
118
|
+
@pending = bytes
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Drops what a restart or resume left after where writing continues.
|
|
122
|
+
def settle!
|
|
123
|
+
return unless @pending
|
|
124
|
+
|
|
125
|
+
@file.truncate(@pending)
|
|
126
|
+
@file.seek(@pending)
|
|
127
|
+
@pending = nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def close
|
|
131
|
+
@file.close unless @file.closed?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def close_after_failure(_error); end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The caller's IO, which stays open. It can start over only when it reports its position and can seek back and
|
|
138
|
+
# truncate there, which it does as the next bytes are written, or as the download completes.
|
|
139
|
+
class IoSink
|
|
140
|
+
attr_reader :start
|
|
141
|
+
|
|
142
|
+
def initialize(io, start: :current)
|
|
143
|
+
@io = io
|
|
144
|
+
@start = start == :current ? position : start
|
|
145
|
+
@pending = false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def path
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def write(data)
|
|
153
|
+
settle!
|
|
154
|
+
@io << data
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def position
|
|
158
|
+
@io.pos if @io.respond_to?(:pos)
|
|
159
|
+
rescue IOError, SystemCallError, NotImplementedError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def restartable?
|
|
164
|
+
!@start.nil? && @io.respond_to?(:seek) && @io.respond_to?(:truncate)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def restart!
|
|
168
|
+
@pending = true
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def settle!
|
|
172
|
+
return unless @pending
|
|
173
|
+
|
|
174
|
+
@io.seek(@start)
|
|
175
|
+
@io.truncate(@start)
|
|
176
|
+
@pending = false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Whether a resume could check the bytes written: only when the IO said where they begin.
|
|
180
|
+
def verifiable?
|
|
181
|
+
!@start.nil?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The IO object itself, never a number another object could reuse.
|
|
185
|
+
def identity
|
|
186
|
+
[ :io, @io, @start ]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def matches?(io)
|
|
190
|
+
io.equal?(@io)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def holds?(bytes)
|
|
194
|
+
!@start.nil? && position == @start + bytes
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A digest of the bytes written from start, read back chunk bytes at a time without moving where the IO writes
|
|
198
|
+
# next: from a StringIO's string, with pread, by seeking back and reading, or, for a File it cannot read, from its
|
|
199
|
+
# path while that is still the same file. A Tempfile and other delegators read back as what they delegate to.
|
|
200
|
+
# false when it holds fewer bytes; nil when it cannot give them back at all.
|
|
201
|
+
def prefix_digest(bytes, chunk)
|
|
202
|
+
return if @start.nil?
|
|
203
|
+
return string_digest(bytes, chunk) if @io.is_a?(StringIO)
|
|
204
|
+
|
|
205
|
+
digest = read_back_digest(bytes, chunk)
|
|
206
|
+
digest.nil? ? path_digest(bytes, chunk) : digest
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def resume_at!(_bytes); end
|
|
210
|
+
|
|
211
|
+
def close; end
|
|
212
|
+
|
|
213
|
+
private def string_digest(bytes, chunk)
|
|
214
|
+
string = @io.string
|
|
215
|
+
return false if string.bytesize < @start + bytes
|
|
216
|
+
|
|
217
|
+
digest = OpenSSL::Digest.new('SHA256')
|
|
218
|
+
(0...bytes).step(chunk) { |offset| digest.update(string.byteslice(@start + offset, [ chunk, bytes - offset ].min)) }
|
|
219
|
+
digest
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private def read_back_digest(bytes, chunk)
|
|
223
|
+
if @io.respond_to?(:pread)
|
|
224
|
+
pread_digest(bytes, chunk)
|
|
225
|
+
elsif @io.respond_to?(:seek) && @io.respond_to?(:read)
|
|
226
|
+
seek_digest(bytes, chunk)
|
|
227
|
+
end
|
|
228
|
+
rescue IOError, SystemCallError, NotImplementedError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private def pread_digest(bytes, chunk)
|
|
233
|
+
digest = OpenSSL::Digest.new('SHA256')
|
|
234
|
+
buffer = String.new(capacity: chunk, encoding: Encoding::BINARY) if DownloadCoordinator.takes_buffer?(@io)
|
|
235
|
+
offset = 0
|
|
236
|
+
while offset < bytes
|
|
237
|
+
wanted = [ bytes - offset, chunk ].min
|
|
238
|
+
data = buffer ? @io.pread(wanted, @start + offset, buffer) : @io.pread(wanted, @start + offset)
|
|
239
|
+
return false if data.empty?
|
|
240
|
+
|
|
241
|
+
digest.update(data)
|
|
242
|
+
offset += data.bytesize
|
|
243
|
+
end
|
|
244
|
+
digest
|
|
245
|
+
rescue EOFError
|
|
246
|
+
false
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private def seek_digest(bytes, chunk)
|
|
250
|
+
resume_from = @io.pos
|
|
251
|
+
begin
|
|
252
|
+
@io.seek(@start)
|
|
253
|
+
DownloadCoordinator.digest_of(@io, bytes, chunk)
|
|
254
|
+
ensure
|
|
255
|
+
@io.seek(resume_from)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private def path_digest(bytes, chunk)
|
|
260
|
+
return unless @io.respond_to?(:path) && @io.respond_to?(:stat) && @io.path
|
|
261
|
+
|
|
262
|
+
::File.open(@io.path, "rb") do |file|
|
|
263
|
+
next unless Checkpoint.identity(file.stat, contents: false) == Checkpoint.identity(@io.stat, contents: false)
|
|
264
|
+
|
|
265
|
+
file.seek(@start)
|
|
266
|
+
DownloadCoordinator.digest_of(file, bytes, chunk)
|
|
267
|
+
end
|
|
268
|
+
rescue IOError, SystemCallError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Pipe readers learn why the download failed before the pipe closes, and a failure to close never replaces the
|
|
273
|
+
# download's own error.
|
|
274
|
+
def close_after_failure(error)
|
|
275
|
+
@io.do_set_error(error) if @io.respond_to?(:do_set_error)
|
|
276
|
+
@io.close
|
|
277
|
+
rescue StandardError
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Opens local_path only once the job gives the download a file slot.
|
|
283
|
+
def self.download_file(job, file, local_path, progress: nil)
|
|
284
|
+
new(job, file, -> { FileSink.new(::File.open(local_path, "wb"), local_path) }, progress: progress).run
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def self.download_content(job, file, io, progress: nil)
|
|
288
|
+
new(job, file, -> { IoSink.new(io) }, progress: progress).run
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def self.resume(job, checkpoint, destination, options, progress: nil)
|
|
292
|
+
checkpoint.resuming(:download) do |lease|
|
|
293
|
+
remote = Files::File.new(checkpoint.path, options)
|
|
294
|
+
open_sink = if checkpoint.local_path
|
|
295
|
+
-> { FileSink.new(::File.open(checkpoint.local_path, "r+b"), checkpoint.local_path) }
|
|
296
|
+
else
|
|
297
|
+
_, io, start = checkpoint.sink
|
|
298
|
+
raise CheckpointError, "Bad parameter: resume the download into the IO it paused in" unless destination.equal?(io)
|
|
299
|
+
|
|
300
|
+
-> { IoSink.new(destination, start: start) }
|
|
301
|
+
end
|
|
302
|
+
new(job, remote, open_sink, checkpoint: checkpoint, lease: lease, progress: progress).run
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Fixes, on the caller's thread, the file's path and any download URL its record holds, the API context from its
|
|
307
|
+
# options, and the transport for its content requests, which refuses a client whose adapter cannot stream.
|
|
308
|
+
def initialize(job, file, open_sink, checkpoint: nil, lease: nil, progress: nil)
|
|
309
|
+
super(job)
|
|
310
|
+
@path = Checkpoint.frozen(file.path)
|
|
311
|
+
@download_uri = Checkpoint.frozen(file.download_uri)
|
|
312
|
+
@options = Coordinator.api_context(file.options)
|
|
313
|
+
@open_sink = open_sink
|
|
314
|
+
@sink = nil
|
|
315
|
+
@lease = lease
|
|
316
|
+
@progress = progress
|
|
317
|
+
@transport = job.transport_for(Files::ApiClient.download_client, download: true)
|
|
318
|
+
@range_bytes = [ Policy::DOWNLOAD_RANGE_BYTES, job.options.buffer_bytes / 4 ].min
|
|
319
|
+
@mode = :probe
|
|
320
|
+
@written = 0
|
|
321
|
+
@counted = 0
|
|
322
|
+
@digest = OpenSSL::Digest.new('SHA256')
|
|
323
|
+
@total = nil
|
|
324
|
+
@version = nil
|
|
325
|
+
@generation = 0
|
|
326
|
+
@open = {}
|
|
327
|
+
@next_number = 0
|
|
328
|
+
@next_write = 0
|
|
329
|
+
@refusals = {}.compare_by_identity
|
|
330
|
+
@write_failure = nil
|
|
331
|
+
@origin = checkpoint
|
|
332
|
+
return unless checkpoint&.version
|
|
333
|
+
|
|
334
|
+
@written = checkpoint.completed_bytes
|
|
335
|
+
@counted = @written
|
|
336
|
+
@version = checkpoint.version
|
|
337
|
+
@total = checkpoint.total_bytes
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private
|
|
341
|
+
|
|
342
|
+
# A destination that cannot be opened, or a resume's that changed or cannot be checked, stops the transfer as it is
|
|
343
|
+
# found (stop!), so a cancellation or pause after it cannot come first, and one before it leaves the checkpoint
|
|
344
|
+
# unspent and the destination as it was.
|
|
345
|
+
def open_resources
|
|
346
|
+
@sink = @open_sink.call
|
|
347
|
+
resume_sink(@origin) if @origin
|
|
348
|
+
rescue StandardError => e
|
|
349
|
+
raise stop!(e)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def close_resources
|
|
353
|
+
@sink&.close
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Before anything is sent or written: the destination must still be the one the paused download wrote to,
|
|
357
|
+
# holding exactly the bytes it wrote, read back a slice at a time into scratch the buffer budget holds, and
|
|
358
|
+
# compared by digest; one that cannot give them back refuses. A pause or cancellation while it waits for that
|
|
359
|
+
# budget leaves the checkpoint unspent. Only then is the checkpoint spent. An unbound download starts its
|
|
360
|
+
# destination over instead, as its first new bytes are written.
|
|
361
|
+
def resume_sink(checkpoint)
|
|
362
|
+
changed = @sink.path ? "Bad parameter: the local file changed after the download paused" : "Bad parameter: the IO changed after the download paused"
|
|
363
|
+
raise CheckpointError, changed unless @sink.identity == checkpoint.sink
|
|
364
|
+
|
|
365
|
+
if checkpoint.state[:restart]
|
|
366
|
+
@lease&.start!
|
|
367
|
+
return restart_sink
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
completed = checkpoint.completed_bytes
|
|
371
|
+
raise CheckpointError, changed unless @sink.holds?(completed)
|
|
372
|
+
|
|
373
|
+
chunk = [ VERIFY_CHUNK_BYTES, @range_bytes ].min
|
|
374
|
+
scratch = reserve(chunk)
|
|
375
|
+
begin
|
|
376
|
+
digest = @sink.prefix_digest(completed, chunk)
|
|
377
|
+
ensure
|
|
378
|
+
release(scratch)
|
|
379
|
+
end
|
|
380
|
+
raise CheckpointError, "Bad parameter: the bytes already downloaded cannot be read back to check them" if digest.nil?
|
|
381
|
+
raise CheckpointError, changed unless digest && digest.hexdigest == checkpoint.state[:digest]
|
|
382
|
+
|
|
383
|
+
@lease&.start!
|
|
384
|
+
@digest = digest
|
|
385
|
+
@sink.resume_at!(completed)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# The download, from its URL's lookup to its last write. Any error on the way is raised only when it came before
|
|
389
|
+
# any cancellation or pause (Coordinator#run), and a cancellation or pause, such as one from the last progress
|
|
390
|
+
# call, is raised in place of a success.
|
|
391
|
+
def transfer
|
|
392
|
+
@url = @download_uri || load_url
|
|
393
|
+
raise stop!(Files::APIError.new("The download URL the API returned is not usable")) unless http_url?(@url)
|
|
394
|
+
|
|
395
|
+
@current = [ @url, @generation, StorageRetry.expires_at(@url) ].freeze
|
|
396
|
+
@started_at = Clock.now
|
|
397
|
+
@start_written = @written
|
|
398
|
+
# Every byte of a resumed download was written before its pause: storage proves its version with its last byte.
|
|
399
|
+
@mode = :proof if @version && @written >= @total
|
|
400
|
+
if fetch_head == :bound
|
|
401
|
+
bind
|
|
402
|
+
@next_offset = @written
|
|
403
|
+
pump
|
|
404
|
+
end
|
|
405
|
+
@job.synchronize { observe_stops }
|
|
406
|
+
raise_outcome
|
|
407
|
+
@sink.settle!
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# A refused transfer or a failed write that ended the download first closes a caller's IO.
|
|
412
|
+
def after_failure(error)
|
|
413
|
+
@sink&.close_after_failure(error) if closes_destination?(error)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# The opening range, or the whole response, on the caller's thread, with its own retries and renewals, each
|
|
417
|
+
# request under one of the job's request permits and only once no throttle holds its destination back. A
|
|
418
|
+
# throttle it gets holds the destination back for every transfer of the job, from before its permit is given back
|
|
419
|
+
# (fetch_once). An expired URL is renewed before it is sent, at most twice; a renewal after storage refused an
|
|
420
|
+
# expired URL is one of the retries. Returns :bound when ranges remain, or :done. What ends it stops the transfer
|
|
421
|
+
# as it is found (stop!, refusal).
|
|
422
|
+
def fetch_head
|
|
423
|
+
attempts = 0
|
|
424
|
+
renewals = 0
|
|
425
|
+
loop do
|
|
426
|
+
check_control!
|
|
427
|
+
if StorageRetry.expired?(@current[2])
|
|
428
|
+
raise stop!(Files::APIError.new(EXPIRED, http_status: 403)) if renewals >= RENEWALS
|
|
429
|
+
|
|
430
|
+
renewals += 1
|
|
431
|
+
renew_url
|
|
432
|
+
next
|
|
433
|
+
end
|
|
434
|
+
begin
|
|
435
|
+
result = fetch_once
|
|
436
|
+
rescue NetworkFailure => e
|
|
437
|
+
raise stop!(e.original) unless e.retryable? && attempts < @max_retries && (@written == @attempt_start || @sink.restartable?)
|
|
438
|
+
|
|
439
|
+
attempts += 1
|
|
440
|
+
retry_head(StorageRetry::RETRY_DELAY)
|
|
441
|
+
restart_sink unless @written == @attempt_start
|
|
442
|
+
next
|
|
443
|
+
rescue Passthrough => e
|
|
444
|
+
raise stop!(e.error) unless e.error.is_a?(ControlStop)
|
|
445
|
+
|
|
446
|
+
# A cancellation or pause keeps what was written; a resumed unbound download starts over.
|
|
447
|
+
check_control!
|
|
448
|
+
raise Files::Error, "The transfer stopped"
|
|
449
|
+
end
|
|
450
|
+
next @mode = :whole if result == :unbound
|
|
451
|
+
return result if result.is_a?(Symbol)
|
|
452
|
+
|
|
453
|
+
response = result
|
|
454
|
+
raise refusal(FrontendResponseError.new) if StorageRetry.frontend?(response.headers)
|
|
455
|
+
return :done if response.status == 416 && @written.zero? && response.header("content-range").to_s.strip == "bytes */0"
|
|
456
|
+
raise refusal(VersionChangedError.new) if response.status == 412
|
|
457
|
+
|
|
458
|
+
decision, delay = if response.status == 403 && StorageRetry.expired?(@current[2])
|
|
459
|
+
[ :renew, 0.0 ]
|
|
460
|
+
else
|
|
461
|
+
StorageRetry.classify(response.status, response.headers, response.body, truncated: response.truncated)
|
|
462
|
+
end
|
|
463
|
+
raise refusal(refused(response)) if decision == :refuse || attempts >= @max_retries
|
|
464
|
+
|
|
465
|
+
attempts += 1
|
|
466
|
+
retry_head(delay)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def retry_head(delay)
|
|
471
|
+
@job.synchronize { @job.count_retry }
|
|
472
|
+
wait_before_retry(delay)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# One request for the head, under a request permit it holds only while the request is in flight: the opening
|
|
476
|
+
# range, the whole file, or, for a proof, the file's last byte. A throttle it gets holds the destination back as
|
|
477
|
+
# the permit is given back, under the same lock, so a request waiting for the permit is never sent before it.
|
|
478
|
+
def fetch_once
|
|
479
|
+
@attempt_start = @written
|
|
480
|
+
headers = { "Accept-Encoding" => "identity" }
|
|
481
|
+
unless @mode == :whole
|
|
482
|
+
first = @mode == :proof ? @total - 1 : @written
|
|
483
|
+
headers["Range"] = "bytes=#{first}-#{@mode == :proof ? first : first + @range_bytes - 1}"
|
|
484
|
+
end
|
|
485
|
+
headers["If-Match"] = @version if @version
|
|
486
|
+
reservation = reserve(@mode == :proof ? 1 : @range_bytes) unless @mode == :whole
|
|
487
|
+
begin
|
|
488
|
+
check_control! unless @job.acquire_request(@control, @url) == :running
|
|
489
|
+
begin
|
|
490
|
+
outcome = nil
|
|
491
|
+
response = @transport.perform(:get, @url, headers, @connections) { |http| outcome = head_response(http) }
|
|
492
|
+
throttle = head_throttle(response)
|
|
493
|
+
ensure
|
|
494
|
+
@job.release_request(@url, throttle)
|
|
495
|
+
end
|
|
496
|
+
return response unless response.status.between?(200, 299)
|
|
497
|
+
|
|
498
|
+
case outcome[0]
|
|
499
|
+
when :bound, :complete
|
|
500
|
+
_, data, total, etag = outcome
|
|
501
|
+
@version ||= etag if outcome[0] == :bound
|
|
502
|
+
@total = total
|
|
503
|
+
write!(data)
|
|
504
|
+
wrote(data.bytesize)
|
|
505
|
+
@written < @total ? :bound : :done
|
|
506
|
+
when :streamed, :proven then :done
|
|
507
|
+
else :unbound
|
|
508
|
+
end
|
|
509
|
+
ensure
|
|
510
|
+
release(reservation)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# The seconds a head response's throttle holds its destination back, as fetch_head classifies it, or nil: never
|
|
515
|
+
# for a success, nor for the web app's response, which fetch_head refuses instead.
|
|
516
|
+
def head_throttle(response)
|
|
517
|
+
return if response.status.between?(200, 299) || StorageRetry.frontend?(response.headers)
|
|
518
|
+
|
|
519
|
+
decision, delay = StorageRetry.classify(response.status, response.headers, response.body, truncated: response.truncated)
|
|
520
|
+
delay if decision == :throttle
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Inside the response, before anything of it is written.
|
|
524
|
+
def head_response(http)
|
|
525
|
+
raise refusal(FrontendResponseError.new) if http.key?(StorageRetry::FRONTEND_HEADER)
|
|
526
|
+
|
|
527
|
+
case http.code
|
|
528
|
+
when "206"
|
|
529
|
+
raise refusal(mismatch) if @mode == :whole
|
|
530
|
+
|
|
531
|
+
first, last, total = content_range(http["content-range"])
|
|
532
|
+
return proof(http, first, last, total) if @mode == :proof
|
|
533
|
+
|
|
534
|
+
length = last && (last - first + 1)
|
|
535
|
+
unless first == @written && length && last < total && length == [ @range_bytes, total - first ].min && http.content_length == length &&
|
|
536
|
+
(@total.nil? || total == @total)
|
|
537
|
+
raise refusal(mismatch)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
etag = http["etag"]
|
|
541
|
+
raise refusal(VersionChangedError.new) if @version && etag && etag != @version
|
|
542
|
+
return [ :bound, read_exact(http, length), total, etag || @version ] if @version || strong?(etag)
|
|
543
|
+
return [ :complete, read_exact(http, length), total ] if first.zero? && last == total - 1
|
|
544
|
+
|
|
545
|
+
[ :unbound ]
|
|
546
|
+
when "200"
|
|
547
|
+
# A download bound to a version takes a whole response only of that version, and never as its proof.
|
|
548
|
+
raise refusal(VersionChangedError.new) if @version && http["etag"] != @version
|
|
549
|
+
raise refusal(mismatch) if @mode == :proof
|
|
550
|
+
|
|
551
|
+
stream_whole(http)
|
|
552
|
+
[ :streamed ]
|
|
553
|
+
else
|
|
554
|
+
raise refusal(mismatch)
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# A resumed download's proof that storage still has its version: the file's last byte, answered 206 under
|
|
559
|
+
# If-Match with the exact Content-Range and length and no other ETag. Nothing of it is written.
|
|
560
|
+
def proof(http, first, last, total)
|
|
561
|
+
raise refusal(mismatch) unless first == @total - 1 && last == first && total == @total && http.content_length == 1
|
|
562
|
+
|
|
563
|
+
etag = http["etag"]
|
|
564
|
+
raise refusal(VersionChangedError.new) if etag && etag != @version
|
|
565
|
+
|
|
566
|
+
read_exact(http, 1)
|
|
567
|
+
[ :proven ]
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# A whole response, written as it arrives from its first byte.
|
|
571
|
+
def stream_whole(http)
|
|
572
|
+
raise refusal(mismatch) if @written.positive? && !@sink.restartable?
|
|
573
|
+
|
|
574
|
+
restart_sink if @written.positive?
|
|
575
|
+
expected = http.chunked? ? nil : http.content_length
|
|
576
|
+
received = 0
|
|
577
|
+
http.read_body do |chunk|
|
|
578
|
+
raise Passthrough.new(ControlStop.new), cause: nil unless @control.nil? || @control.state == :running
|
|
579
|
+
|
|
580
|
+
begin
|
|
581
|
+
write!(chunk)
|
|
582
|
+
wrote(chunk.bytesize)
|
|
583
|
+
rescue StandardError => e
|
|
584
|
+
raise Passthrough.new(e), cause: nil
|
|
585
|
+
end
|
|
586
|
+
received += chunk.bytesize
|
|
587
|
+
end
|
|
588
|
+
raise EOFError, "Transfer response ended after #{received} of #{expected} bytes" if expected && received != expected
|
|
589
|
+
|
|
590
|
+
@total = received
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Starts the destination over, just before a request that writes it from the first byte again.
|
|
594
|
+
def restart_sink
|
|
595
|
+
@sink.restart!
|
|
596
|
+
@written = 0
|
|
597
|
+
@digest = OpenSSL::Digest.new('SHA256')
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def bind
|
|
601
|
+
host = ::URI.parse(@url).host
|
|
602
|
+
options = @job.options
|
|
603
|
+
key, policy = if @transport.serialized?
|
|
604
|
+
[ [ :download, :client, host ], Policy.fixed(1) ]
|
|
605
|
+
elsif options.automatic?
|
|
606
|
+
[ [ :download, host ], Policy.download(max_concurrency: options.max_concurrency) ]
|
|
607
|
+
else
|
|
608
|
+
[ [ :download, :fixed, host ], Policy.fixed(options.concurrency) ]
|
|
609
|
+
end
|
|
610
|
+
@controller = @job.synchronize { @job.controller(key) { policy } }
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def plan
|
|
614
|
+
renew_pieces
|
|
615
|
+
while @stop.nil? && @next_offset < @total && @queue.size < @controller.limit
|
|
616
|
+
length = [ @range_bytes, @total - @next_offset ].min
|
|
617
|
+
piece = Piece.new(number: @next_number, offset: @next_offset, bytesize: length, attempts: 0, renewals: 0, ready_at: 0, generation: @generation)
|
|
618
|
+
@open[piece.number] = piece
|
|
619
|
+
enqueue(piece)
|
|
620
|
+
@next_offset += length
|
|
621
|
+
@next_number += 1
|
|
622
|
+
end
|
|
623
|
+
rescue StandardError => e
|
|
624
|
+
stop!(e)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def wants_planning?
|
|
628
|
+
!@renewals.empty? || (@next_offset < @total && @queue.size < @controller.limit)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def finished?
|
|
632
|
+
@next_offset >= @total && @open.empty?
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Renews the URL once for the ranges that found theirs expired, then sends them again. A range that already
|
|
636
|
+
# used a newer URL just goes again. A range whose renewals still give an expired URL stops the download unsent.
|
|
637
|
+
def renew_pieces
|
|
638
|
+
pieces = @renewals.dup
|
|
639
|
+
@renewals.clear
|
|
640
|
+
current = pieces.select { |piece| piece.generation >= @generation }
|
|
641
|
+
exhausted = current.find { |piece| piece.renewals >= RENEWALS }
|
|
642
|
+
if exhausted
|
|
643
|
+
fail_item(exhausted, refusal(Files::APIError.new("Transfer request failed", http_status: 403)))
|
|
644
|
+
elsif !current.empty?
|
|
645
|
+
current.each { |piece| piece.renewals += 1 }
|
|
646
|
+
renew_url
|
|
647
|
+
end
|
|
648
|
+
pieces.each do |piece|
|
|
649
|
+
next if piece.equal?(exhausted)
|
|
650
|
+
|
|
651
|
+
piece.ready_at = 0
|
|
652
|
+
enqueue(piece)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# The new target, its URL, generation and expiry, is made whole and then published as one reference, so an
|
|
657
|
+
# attempt that looks at it for a newer generation never sees part of it beside part of the target before it.
|
|
658
|
+
def renew_url
|
|
659
|
+
url = load_url
|
|
660
|
+
raise stop!(Files::APIError.new("The download URL the API returned is not usable")) unless http_url?(url)
|
|
661
|
+
|
|
662
|
+
target = [ url, @generation + 1, StorageRetry.expires_at(url) ].freeze
|
|
663
|
+
@url, @generation = target
|
|
664
|
+
@current = target
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# The download URL the API gives for the file now, asked with the context the download began with; the answer's
|
|
668
|
+
# own context, such as the regional origin a lockout region mismatch moved it to, carries on to the lookups after.
|
|
669
|
+
# The lookup's failure stops the transfer as it arrives (stop!).
|
|
670
|
+
def load_url
|
|
671
|
+
remote = begin
|
|
672
|
+
Files::File.download(@path, {}, @options)
|
|
673
|
+
rescue StandardError => e
|
|
674
|
+
raise stop!(e)
|
|
675
|
+
end
|
|
676
|
+
adopt_context(remote)
|
|
677
|
+
remote.download_uri
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def reservation_bytes(piece)
|
|
681
|
+
piece.bytesize
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# An expired URL is never sent; renew_pieces stops the download once a range's renewals run out.
|
|
685
|
+
def expiring?(piece)
|
|
686
|
+
return false unless StorageRetry.expired?(@current[2])
|
|
687
|
+
|
|
688
|
+
piece.generation = @generation
|
|
689
|
+
true
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Every range goes to the URL the download has now, with its generation and expiry.
|
|
693
|
+
def request_target(_piece)
|
|
694
|
+
@current
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# On a worker thread, to the URL and generation the range was admitted for (target), even when the URL has been
|
|
698
|
+
# renewed since. A 403 renews it when that URL's own expiry has passed, or when a newer URL has been published
|
|
699
|
+
# since; any other 403 refuses it. timing starts with the send and ends once the range's whole body has arrived and
|
|
700
|
+
# been checked; the range's bytes are charged to the buffer budget already, and read straight into one String of
|
|
701
|
+
# its length.
|
|
702
|
+
def attempt_item(piece, target, connections, timing = nil)
|
|
703
|
+
url, generation, expires_at = target
|
|
704
|
+
piece.generation = generation
|
|
705
|
+
headers = { "Range" => "bytes=#{piece.offset}-#{piece.offset + piece.bytesize - 1}", "Accept-Encoding" => "identity", "If-Match" => @version }
|
|
706
|
+
response = @transport.perform(:get, url, headers, connections, timing: timing) { |http| range_body(http, piece) }
|
|
707
|
+
return [ :ok, response.body, piece.bytesize ] if response.status.between?(200, 299)
|
|
708
|
+
return [ :fail, refusal(FrontendResponseError.new) ] if StorageRetry.frontend?(response.headers)
|
|
709
|
+
return [ :fail, refusal(VersionChangedError.new) ] if response.status == 412
|
|
710
|
+
return [ :renew, refusal(refused(response)) ] if response.status == 403 && (StorageRetry.expired?(expires_at) || generation < @current[1])
|
|
711
|
+
|
|
712
|
+
decision, delay = StorageRetry.classify(response.status, response.headers, response.body, truncated: response.truncated)
|
|
713
|
+
case decision
|
|
714
|
+
when :throttle then [ :retry, delay, :throttle, refusal(refused(response)) ]
|
|
715
|
+
when :retry then [ :retry, delay, :failure, refusal(refused(response)) ]
|
|
716
|
+
else [ :fail, refusal(refused(response)) ]
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def range_body(http, piece)
|
|
721
|
+
raise refusal(FrontendResponseError.new) if http.key?(StorageRetry::FRONTEND_HEADER)
|
|
722
|
+
raise refusal(mismatch) unless http.code == "206"
|
|
723
|
+
|
|
724
|
+
first, last, total = content_range(http["content-range"])
|
|
725
|
+
raise refusal(mismatch) unless first == piece.offset && last == piece.offset + piece.bytesize - 1 && total == @total && http.content_length == piece.bytesize
|
|
726
|
+
|
|
727
|
+
etag = http["etag"]
|
|
728
|
+
raise refusal(VersionChangedError.new) if etag && etag != @version
|
|
729
|
+
|
|
730
|
+
read_exact(http, piece.bytesize)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def read_exact(http, length)
|
|
734
|
+
data = String.new(capacity: length, encoding: Encoding::BINARY)
|
|
735
|
+
http.read_body do |chunk|
|
|
736
|
+
raise refusal(mismatch) if data.bytesize + chunk.bytesize > length
|
|
737
|
+
|
|
738
|
+
data << chunk
|
|
739
|
+
end
|
|
740
|
+
raise EOFError, "Transfer response ended after #{data.bytesize} of #{length} bytes" if data.bytesize < length
|
|
741
|
+
|
|
742
|
+
data
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# With the lock held: keeps a range until the ones before it are written.
|
|
746
|
+
def accept(piece, data)
|
|
747
|
+
piece.data = data
|
|
748
|
+
piece
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# On the caller's thread: writes the ranges that are next in order. They are still written after a pause, so
|
|
752
|
+
# its checkpoint keeps them, but not after a failure or cancellation.
|
|
753
|
+
def deliver(_completed)
|
|
754
|
+
while @stop.nil? || @stop == :paused
|
|
755
|
+
piece = @open[@next_write]
|
|
756
|
+
break unless piece&.data
|
|
757
|
+
|
|
758
|
+
write!(piece.data)
|
|
759
|
+
@open.delete(piece.number)
|
|
760
|
+
@job.post { discard(piece) }
|
|
761
|
+
@next_write += 1
|
|
762
|
+
wrote(piece.bytesize)
|
|
763
|
+
end
|
|
764
|
+
rescue StandardError => e
|
|
765
|
+
stop!(e)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# On the caller's thread, after bytes were written: counts those not written before, and reports progress with
|
|
769
|
+
# total_bytes (nil until known), written_bytes, elapsed_seconds of this call and bytes_per_second, its written
|
|
770
|
+
# bytes over them. Anything progress raises stops the transfer as it is raised (stop!).
|
|
771
|
+
def wrote(bytes)
|
|
772
|
+
@written += bytes
|
|
773
|
+
if @written > @counted
|
|
774
|
+
fresh = @written - @counted
|
|
775
|
+
@counted = @written
|
|
776
|
+
@job.synchronize { @job.count_accepted(fresh) }
|
|
777
|
+
end
|
|
778
|
+
return unless @progress
|
|
779
|
+
|
|
780
|
+
elapsed = Clock.now - @started_at
|
|
781
|
+
begin
|
|
782
|
+
@progress.call({ total_bytes: @total, written_bytes: @written, elapsed_seconds: elapsed,
|
|
783
|
+
bytes_per_second: elapsed.positive? ? [ @written - @start_written, 0 ].max / elapsed : 0.0 }
|
|
784
|
+
)
|
|
785
|
+
rescue StandardError => e
|
|
786
|
+
raise stop!(e)
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def all_items
|
|
791
|
+
@open.values
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# A range is on the network until its whole body has arrived and been checked.
|
|
795
|
+
def measured_through_body?
|
|
796
|
+
true
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# Keeps a digest of the bytes written, so resuming can tell they are unchanged, never the bytes themselves. nil
|
|
800
|
+
# before the destination is open, as when a pause comes while it opens, and when resuming could neither start
|
|
801
|
+
# over nor check those bytes.
|
|
802
|
+
def checkpoint
|
|
803
|
+
return if @sink.nil?
|
|
804
|
+
|
|
805
|
+
state = {}
|
|
806
|
+
if @version.nil?
|
|
807
|
+
return unless @sink.restartable?
|
|
808
|
+
|
|
809
|
+
state = { restart: true }
|
|
810
|
+
else
|
|
811
|
+
return unless @sink.verifiable? && @digest
|
|
812
|
+
|
|
813
|
+
state = { digest: @digest.hexdigest }
|
|
814
|
+
end
|
|
815
|
+
Checkpoint.new(direction: :download, path: @path, local_path: @sink.path, total_bytes: @total, completed_bytes: @version ? @written : 0,
|
|
816
|
+
sink: @sink.identity, version: @version, state: state
|
|
817
|
+
)
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def check_control!
|
|
821
|
+
@job.synchronize { observe_stops }
|
|
822
|
+
raise_outcome
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def wait_before_retry(seconds)
|
|
826
|
+
deadline = Clock.now + seconds
|
|
827
|
+
@job.synchronize do
|
|
828
|
+
loop do
|
|
829
|
+
observe_stops
|
|
830
|
+
remaining = deadline - Clock.now
|
|
831
|
+
break if @stop || !remaining.positive?
|
|
832
|
+
|
|
833
|
+
@job.wait(remaining)
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
raise_outcome
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Waits for the buffer budget to hold bytes on the caller's thread, unless the transfer stops first.
|
|
840
|
+
def reserve(bytes)
|
|
841
|
+
@job.synchronize do
|
|
842
|
+
loop do
|
|
843
|
+
observe_stops
|
|
844
|
+
break if @stop
|
|
845
|
+
return @job.budget.reserve(bytes) if @job.budget.available?(bytes)
|
|
846
|
+
|
|
847
|
+
@job.wait
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
raise_outcome
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def release(reservation)
|
|
854
|
+
@job.post { @job.budget.release(reservation) } if reservation
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# On the caller's thread. A failed write stops the transfer as it fails (stop!).
|
|
858
|
+
def write!(data)
|
|
859
|
+
@sink.write(data)
|
|
860
|
+
@digest&.update(data)
|
|
861
|
+
rescue StandardError => e
|
|
862
|
+
@write_failure = e
|
|
863
|
+
raise stop!(e)
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
# Marks error as the kind that closes a caller's IO: storage refused, or answered something other than asked. On
|
|
867
|
+
# the caller's thread, where it always ends the transfer, it also stops it as it is found (stop!); a worker's goes
|
|
868
|
+
# back through its request's outcome instead.
|
|
869
|
+
def refusal(error)
|
|
870
|
+
@job.synchronize { @refusals[error] = true }
|
|
871
|
+
Thread.current.equal?(@thread) ? stop!(error) : error
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def closes_destination?(error)
|
|
875
|
+
error.equal?(@write_failure) || @job.synchronize { @refusals.key?(error) }
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def public_error(error)
|
|
879
|
+
error.is_a?(NetworkFailure) ? error.original : error
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def refused(response)
|
|
883
|
+
Files::APIError.new("Transfer request failed", http_status: response.status)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def mismatch
|
|
887
|
+
Files::APIError.new(MISMATCH)
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def strong?(etag)
|
|
891
|
+
etag.is_a?(String) && etag.match?(/\A"[^"]*"\z/)
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def content_range(value)
|
|
895
|
+
match = value.to_s.match(/\Abytes (\d+)-(\d+)\/(\d+)\z/)
|
|
896
|
+
match ? match.captures.map(&:to_i) : []
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def http_url?(url)
|
|
900
|
+
url.is_a?(String) && %w[http https].include?(::URI.parse(url).scheme)
|
|
901
|
+
rescue ::URI::InvalidURIError
|
|
902
|
+
false
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
end
|