Repository navigation
perf(windows): stream Cloud Files hydration with one range request #519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
71e748d
perf(windows): stream Cloud Files hydration with one range request
veryCrunchy 4ab4195
docs(changes): link the streamed hydration fragment to its pull request
veryCrunchy 0717c61
chore(website): refresh marketing captures
obiente-automations[bot] d0cff66
fix(windows): cancel a stalled hydration stream immediately
veryCrunchy 3133473
Merge remote-tracking branch 'origin/perf/windows-cloud-files-hydrati…
veryCrunchy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| category: fix | ||
| issue: none | ||
| pull: 519 | ||
| platforms: windows | ||
| user-facing: yes | ||
|
|
||
| Opening a cloud-only file in File Explorer now downloads it with one streamed request instead of one server request per 4 MiB, so large photos open about as fast as a normal download. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
108 changes: 108 additions & 0 deletions
108
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileRangeTransport.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| package dev.obiente.nextcloudnative.app | ||
|
|
||
| import java.io.InputStream | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
| import java.util.concurrent.atomic.AtomicReference | ||
| import okhttp3.Call | ||
| import okhttp3.OkHttpClient | ||
| import okhttp3.Request | ||
| import okhttp3.ResponseBody | ||
|
|
||
| /** | ||
| * Generation-pinned Files WebDAV range requests for one [NextcloudFileRangeSession]. | ||
| * | ||
| * Every request sends If-Match with the pinned ETag, requires HTTP 206 with exactly the requested | ||
| * Content-Range, and fails when the body is shorter or longer than that range. [stream] serves a | ||
| * whole range with one response and hands it over chunk by chunk as bytes arrive, so a large | ||
| * hydration costs one server round trip instead of one per chunk. | ||
| */ | ||
| internal class DesktopFileRangeTransport( | ||
| private val client: OkHttpClient, | ||
| private val url: String, | ||
| private val authorization: String, | ||
| private val userAgent: String, | ||
| private val etag: String, | ||
| private val size: Long, | ||
| private val closed: AtomicBoolean, | ||
| private val recordFailure: (startedNanos: Long, attempt: JvmNetworkRequestAttempt, failure: Throwable) -> Unit, | ||
| ) { | ||
| private val activeCall = AtomicReference<Call?>(null) | ||
|
|
||
| fun cancel() { | ||
| activeCall.get()?.cancel() | ||
| } | ||
|
|
||
| fun read(offset: Long, length: Int): ByteArray = execute(offset, length.toLong()) { body -> | ||
| body.byteStream().use { input -> input.readExactly(length).also { input.requireEnd() } } | ||
| } | ||
|
|
||
| fun stream(offset: Long, length: Long, chunkBytes: Int, onChunk: (offset: Long, bytes: ByteArray) -> Unit) { | ||
| require(chunkBytes > 0) { "The file range chunk size must be greater than zero." } | ||
| execute(offset, length) { body -> | ||
| body.byteStream().use { input -> | ||
| var position = 0L | ||
| while (position < length) { | ||
| val chunk = input.readExactly(minOf(chunkBytes.toLong(), length - position).toInt()) | ||
| onChunk(offset + position, chunk) | ||
| position += chunk.size | ||
| } | ||
| input.requireEnd() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun <T> execute(offset: Long, length: Long, consume: (ResponseBody) -> T): T { | ||
| check(!closed.get()) { "The file range session is closed." } | ||
| require(offset >= 0L) { "The file range offset must not be negative." } | ||
| require(length > 0L) { "The file range length must be greater than zero." } | ||
| require(offset <= size && length <= size - offset) { "The requested file range exceeds the file size." } | ||
| val endInclusive = Math.addExact(offset, length - 1L) | ||
| val started = System.nanoTime() | ||
| val networkAttempt = JvmNetworkRequestAttempt() | ||
| val request = Request.Builder() | ||
| .url(url) | ||
| .get() | ||
| .header("Accept", "application/octet-stream") | ||
| .header("Range", "bytes=$offset-$endInclusive") | ||
| .header("If-Match", etag) | ||
| .header("User-Agent", userAgent) | ||
| .header("Authorization", authorization) | ||
| .tag(JvmNetworkRequestAttempt::class.java, networkAttempt) | ||
| .build() | ||
| val call = client.newCall(request) | ||
| check(activeCall.compareAndSet(null, call)) { "Only one file range read can run at a time." } | ||
| if (closed.get()) call.cancel() | ||
| try { | ||
| call.execute().use { response -> | ||
| if (response.code != 206) throw NextcloudFileRangeHttpException(response.code) | ||
| check(isExactHttpByteContentRange(response.header("Content-Range"), offset, endInclusive)) { | ||
| "The server returned a different file range than requested." | ||
| } | ||
| val contentLength = response.body.contentLength() | ||
| if (contentLength in 0 until length) throw JvmNetworkResponseTruncatedIOException() | ||
| check(contentLength == -1L || contentLength == length) { "The server returned an incomplete file range." } | ||
| return consume(response.body) | ||
| } | ||
| } catch (failure: Throwable) { | ||
| recordFailure(started, networkAttempt, failure) | ||
| throw failure | ||
| } finally { | ||
| activeCall.compareAndSet(call, null) | ||
| } | ||
| } | ||
|
|
||
| private fun InputStream.readExactly(length: Int): ByteArray { | ||
| val bytes = ByteArray(length) | ||
| var filled = 0 | ||
| while (filled < length) { | ||
| val read = read(bytes, filled, length - filled) | ||
| if (read < 0) throw JvmNetworkResponseTruncatedIOException() | ||
| filled += read | ||
| } | ||
| return bytes | ||
| } | ||
|
|
||
| private fun InputStream.requireEnd() { | ||
| check(read() == -1) { "The server returned more data than the requested file range." } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
125 changes: 125 additions & 0 deletions
125
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesHydration.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| package dev.obiente.nextcloudnative.app | ||
|
|
||
| import java.util.concurrent.CancellationException | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
| import java.util.concurrent.atomic.AtomicReference | ||
|
|
||
| internal const val WINDOWS_CLOUD_ALIGNMENT = 4 * 1024L | ||
|
|
||
| /** Bytes handed to CfExecute at a time; small enough that an opening app sees data early. */ | ||
| internal const val WINDOWS_CLOUD_HYDRATION_CHUNK_BYTES = 1024 * 1024 | ||
|
|
||
| /** | ||
| * Cancellation for one Cloud Files callback request. [cancel] also closes the read handle that | ||
| * the request has open, so a hydration blocked on a slow or stalled response stops at once | ||
| * instead of waiting for the next chunk or a network timeout. | ||
| */ | ||
| internal class WindowsCloudRequestCancellation { | ||
| private val cancelled = AtomicBoolean(false) | ||
| private val activeHandle = AtomicReference<AutoCloseable?>(null) | ||
|
|
||
| fun get(): Boolean = cancelled.get() | ||
|
|
||
| fun cancel() { | ||
| cancelled.set(true) | ||
| activeHandle.getAndSet(null)?.close() | ||
| } | ||
|
|
||
| /** Runs [block] with [handle] closable by [cancel]; a request cancelled first never starts. */ | ||
| fun <T> whileOpen(handle: AutoCloseable, block: () -> T): T { | ||
| activeHandle.set(handle) | ||
| try { | ||
| if (cancelled.get()) throw CancellationException("Windows cancelled the request.") | ||
| return block() | ||
| } finally { | ||
| activeHandle.compareAndSet(handle, null) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Read access to one pinned remote file generation for hydration. */ | ||
| internal interface WindowsCloudFileReadHandle : AutoCloseable { | ||
| val size: Long | ||
| fun read(offset: Long, length: Int): ByteArray | ||
|
|
||
| /** | ||
| * Delivers a range in order as [chunkBytes] chunks. Backends that can stream serve it with one | ||
| * request; this fallback costs one request per chunk. | ||
| */ | ||
| fun stream(offset: Long, length: Long, chunkBytes: Int, onChunk: (offset: Long, bytes: ByteArray) -> Unit) { | ||
| var position = 0L | ||
| while (position < length) { | ||
| val chunk = minOf(chunkBytes.toLong(), length - position).toInt() | ||
| onChunk(offset + position, read(offset + position, chunk)) | ||
| position += chunk | ||
| } | ||
| } | ||
| } | ||
|
|
||
| internal data class WindowsCloudHydrationRange(val offset: Long, val length: Int) { | ||
| init { | ||
| require(offset >= 0L && offset % WINDOWS_CLOUD_ALIGNMENT == 0L) | ||
| require(length > 0) | ||
| } | ||
| } | ||
|
|
||
| /** Produces CfExecute-compatible chunks; only the final range may end unaligned at EOF. */ | ||
| internal fun planWindowsCloudHydration( | ||
| requiredOffset: Long, | ||
| requiredLength: Long, | ||
| fileSize: Long, | ||
| maximumChunkBytes: Int = WINDOWS_CLOUD_HYDRATION_CHUNK_BYTES, | ||
| ): List<WindowsCloudHydrationRange> { | ||
| require(requiredOffset >= 0L && requiredLength > 0L && fileSize > 0L) | ||
| require( | ||
| maximumChunkBytes.toLong() >= WINDOWS_CLOUD_ALIGNMENT && | ||
| maximumChunkBytes.toLong() % WINDOWS_CLOUD_ALIGNMENT == 0L, | ||
| ) | ||
| val requiredEnd = minOf(fileSize, Math.addExact(requiredOffset, requiredLength)) | ||
| require(requiredOffset < requiredEnd) | ||
| val transferEnd = if (requiredEnd == fileSize) { | ||
| fileSize | ||
| } else { | ||
| minOf(fileSize, Math.addExact(requiredEnd, WINDOWS_CLOUD_ALIGNMENT - 1L) / WINDOWS_CLOUD_ALIGNMENT * WINDOWS_CLOUD_ALIGNMENT) | ||
| } | ||
| var cursor = requiredOffset - requiredOffset % WINDOWS_CLOUD_ALIGNMENT | ||
| return buildList { | ||
| while (cursor < transferEnd) { | ||
| val remaining = transferEnd - cursor | ||
| val length = minOf(maximumChunkBytes.toLong(), remaining).toInt() | ||
| add(WindowsCloudHydrationRange(cursor, length)) | ||
| cursor += length | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Serves one FETCH_DATA request. Windows asks for a whole file when an application opens it, so | ||
| * the aligned span is fetched as one streamed range rather than one server request per chunk, | ||
| * and each chunk is transferred as soon as it arrives. Cancelling closes [source], which stops a | ||
| * blocked read; a chunk that arrives after cancellation is never transferred. Any other mismatch | ||
| * fails the request. | ||
| */ | ||
| internal fun hydrateWindowsCloudRange( | ||
| source: WindowsCloudFileReadHandle, | ||
| requiredOffset: Long, | ||
| requiredLength: Long, | ||
| cancellation: WindowsCloudRequestCancellation, | ||
| transfer: (offset: Long, bytes: ByteArray) -> Unit, | ||
| ) { | ||
| val plan = planWindowsCloudHydration(requiredOffset, requiredLength, source.size) | ||
| val start = plan.first().offset | ||
| val end = plan.last().let { it.offset + it.length } | ||
| var next = 0 | ||
| cancellation.whileOpen(source) { | ||
| source.stream(start, end - start, WINDOWS_CLOUD_HYDRATION_CHUNK_BYTES) { offset, bytes -> | ||
| if (cancellation.get()) throw CancellationException("Windows cancelled the hydration request.") | ||
| val expected = plan.getOrNull(next++) | ||
| check(expected != null && offset == expected.offset && bytes.size == expected.length) { | ||
| "The remote file returned an incomplete range." | ||
| } | ||
| transfer(offset, bytes) | ||
| } | ||
| } | ||
| check(next == plan.size) { "The remote file returned an incomplete range." } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.