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