| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| using System; |
| using System.Collections.Concurrent; |
| using System.Diagnostics; |
| using System.IO; |
| using System.Net.Http; |
| using System.Threading; |
| using System.Threading.Tasks; |
| using Apache.Arrow.Adbc.Drivers.Apache.Hive2; |
| using Apache.Arrow.Adbc.Tracing; |
| using K4os.Compression.LZ4.Streams; |
| |
| namespace Apache.Arrow.Adbc.Drivers.Databricks.Reader.CloudFetch |
| { |
| /// <summary> |
| /// Downloads files from URLs. |
| /// </summary> |
| internal sealed class CloudFetchDownloader : ICloudFetchDownloader, IActivityTracer |
| { |
| private readonly ITracingStatement _statement; |
| private readonly BlockingCollection<IDownloadResult> _downloadQueue; |
| private readonly BlockingCollection<IDownloadResult> _resultQueue; |
| private readonly ICloudFetchMemoryBufferManager _memoryManager; |
| private readonly HttpClient _httpClient; |
| private readonly ICloudFetchResultFetcher _resultFetcher; |
| private readonly int _maxParallelDownloads; |
| private readonly bool _isLz4Compressed; |
| private readonly int _maxRetries; |
| private readonly int _retryDelayMs; |
| private readonly int _maxUrlRefreshAttempts; |
| private readonly int _urlExpirationBufferSeconds; |
| private readonly SemaphoreSlim _downloadSemaphore; |
| private Task? _downloadTask; |
| private CancellationTokenSource? _cancellationTokenSource; |
| private bool _isCompleted; |
| private Exception? _error; |
| private readonly object _errorLock = new object(); |
| |
| /// <summary> |
| /// Initializes a new instance of the <see cref="CloudFetchDownloader"/> class. |
| /// </summary> |
| /// <param name="statement">The tracing statement for Activity context.</param> |
| /// <param name="downloadQueue">The queue of downloads to process.</param> |
| /// <param name="resultQueue">The queue to add completed downloads to.</param> |
| /// <param name="memoryManager">The memory buffer manager.</param> |
| /// <param name="httpClient">The HTTP client to use for downloads.</param> |
| /// <param name="resultFetcher">The result fetcher that manages URLs.</param> |
| /// <param name="maxParallelDownloads">The maximum number of parallel downloads.</param> |
| /// <param name="isLz4Compressed">Whether the results are LZ4 compressed.</param> |
| /// <param name="maxRetries">The maximum number of retry attempts.</param> |
| /// <param name="retryDelayMs">The delay between retry attempts in milliseconds.</param> |
| /// <param name="maxUrlRefreshAttempts">The maximum number of URL refresh attempts.</param> |
| /// <param name="urlExpirationBufferSeconds">Buffer time in seconds before URL expiration to trigger refresh.</param> |
| public CloudFetchDownloader( |
| ITracingStatement statement, |
| BlockingCollection<IDownloadResult> downloadQueue, |
| BlockingCollection<IDownloadResult> resultQueue, |
| ICloudFetchMemoryBufferManager memoryManager, |
| HttpClient httpClient, |
| ICloudFetchResultFetcher resultFetcher, |
| int maxParallelDownloads, |
| bool isLz4Compressed, |
| int maxRetries = 3, |
| int retryDelayMs = 500, |
| int maxUrlRefreshAttempts = 3, |
| int urlExpirationBufferSeconds = 60) |
| { |
| _statement = statement ?? throw new ArgumentNullException(nameof(statement)); |
| _downloadQueue = downloadQueue ?? throw new ArgumentNullException(nameof(downloadQueue)); |
| _resultQueue = resultQueue ?? throw new ArgumentNullException(nameof(resultQueue)); |
| _memoryManager = memoryManager ?? throw new ArgumentNullException(nameof(memoryManager)); |
| _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); |
| _resultFetcher = resultFetcher ?? throw new ArgumentNullException(nameof(resultFetcher)); |
| _maxParallelDownloads = maxParallelDownloads > 0 ? maxParallelDownloads : throw new ArgumentOutOfRangeException(nameof(maxParallelDownloads)); |
| _isLz4Compressed = isLz4Compressed; |
| _maxRetries = maxRetries > 0 ? maxRetries : throw new ArgumentOutOfRangeException(nameof(maxRetries)); |
| _retryDelayMs = retryDelayMs > 0 ? retryDelayMs : throw new ArgumentOutOfRangeException(nameof(retryDelayMs)); |
| _maxUrlRefreshAttempts = maxUrlRefreshAttempts > 0 ? maxUrlRefreshAttempts : throw new ArgumentOutOfRangeException(nameof(maxUrlRefreshAttempts)); |
| _urlExpirationBufferSeconds = urlExpirationBufferSeconds > 0 ? urlExpirationBufferSeconds : throw new ArgumentOutOfRangeException(nameof(urlExpirationBufferSeconds)); |
| _downloadSemaphore = new SemaphoreSlim(_maxParallelDownloads, _maxParallelDownloads); |
| _isCompleted = false; |
| } |
| |
| /// <inheritdoc /> |
| public bool IsCompleted => _isCompleted; |
| |
| /// <inheritdoc /> |
| public bool HasError => _error != null; |
| |
| /// <inheritdoc /> |
| public Exception? Error => _error; |
| |
| /// <inheritdoc /> |
| public async Task StartAsync(CancellationToken cancellationToken) |
| { |
| if (_downloadTask != null) |
| { |
| throw new InvalidOperationException("Downloader is already running."); |
| } |
| |
| _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); |
| _downloadTask = DownloadFilesAsync(_cancellationTokenSource.Token); |
| |
| // Wait for the download task to start |
| await Task.Yield(); |
| } |
| |
| /// <inheritdoc /> |
| public async Task StopAsync() |
| { |
| if (_downloadTask == null) |
| { |
| return; |
| } |
| |
| _cancellationTokenSource?.Cancel(); |
| |
| try |
| { |
| await _downloadTask.ConfigureAwait(false); |
| } |
| catch (OperationCanceledException) |
| { |
| // Expected when cancellation is requested |
| } |
| catch (Exception ex) |
| { |
| Debug.WriteLine($"Error stopping downloader: {ex.Message}"); |
| } |
| finally |
| { |
| _cancellationTokenSource?.Dispose(); |
| _cancellationTokenSource = null; |
| _downloadTask = null; |
| } |
| } |
| |
| /// <inheritdoc /> |
| public async Task<IDownloadResult?> GetNextDownloadedFileAsync(CancellationToken cancellationToken) |
| { |
| try |
| { |
| // Check if there's an error before trying to take from the queue |
| if (HasError) |
| { |
| throw new AdbcException("Error in download process", _error ?? new Exception("Unknown error")); |
| } |
| |
| // Try to take the next result from the queue |
| IDownloadResult result = await Task.Run(() => _resultQueue.Take(cancellationToken), cancellationToken); |
| |
| // Check if this is the end of results guard |
| if (result == EndOfResultsGuard.Instance) |
| { |
| _isCompleted = true; |
| return null; |
| } |
| |
| return result; |
| } |
| catch (OperationCanceledException) |
| { |
| // Cancellation was requested |
| return null; |
| } |
| catch (InvalidOperationException) when (_resultQueue.IsCompleted) |
| { |
| // Queue is completed and empty |
| _isCompleted = true; |
| return null; |
| } |
| catch (AdbcException) |
| { |
| // Re-throw AdbcExceptions (these are our own errors) |
| throw; |
| } |
| catch (Exception ex) |
| { |
| // If there's an error, set the error state and propagate it |
| SetError(ex); |
| throw; |
| } |
| } |
| |
| private async Task DownloadFilesAsync(CancellationToken cancellationToken) |
| { |
| await this.TraceActivityAsync(async activity => |
| { |
| await Task.Yield(); |
| |
| int totalFiles = 0; |
| int successfulDownloads = 0; |
| int failedDownloads = 0; |
| long totalBytes = 0; |
| var overallStopwatch = Stopwatch.StartNew(); |
| |
| try |
| { |
| // Keep track of active download tasks |
| var downloadTasks = new ConcurrentDictionary<Task, IDownloadResult>(); |
| var downloadTaskCompletionSource = new TaskCompletionSource<bool>(); |
| |
| // Process items from the download queue until it's completed |
| foreach (var downloadResult in _downloadQueue.GetConsumingEnumerable(cancellationToken)) |
| { |
| // Check if there's an error before processing more downloads |
| if (HasError) |
| { |
| // Add the failed download result to the queue to signal the error |
| // This will be caught by GetNextDownloadedFileAsync |
| break; |
| } |
| |
| // Check if this is the end of results guard |
| if (downloadResult == EndOfResultsGuard.Instance) |
| { |
| // Wait for all active downloads to complete |
| if (downloadTasks.Count > 0) |
| { |
| try |
| { |
| await Task.WhenAll(downloadTasks.Keys).ConfigureAwait(false); |
| } |
| catch (Exception ex) |
| { |
| activity?.AddException(ex, [new("error.context", "cloudfetch.wait_for_downloads")]); |
| // Don't set error here, as individual download tasks will handle their own errors |
| } |
| } |
| |
| // Only add the guard if there's no error |
| if (!HasError) |
| { |
| // Add the guard to the result queue to signal the end of results |
| _resultQueue.Add(EndOfResultsGuard.Instance, cancellationToken); |
| _isCompleted = true; |
| } |
| break; |
| } |
| |
| // This is a real file, count it |
| totalFiles++; |
| |
| // Check if the URL is expired or about to expire |
| if (downloadResult.IsExpiredOrExpiringSoon(_urlExpirationBufferSeconds)) |
| { |
| // Get a refreshed URL before starting the download |
| var refreshedLink = await _resultFetcher.GetUrlAsync(downloadResult.Link.StartRowOffset, cancellationToken); |
| if (refreshedLink != null) |
| { |
| // Update the download result with the refreshed link |
| downloadResult.UpdateWithRefreshedLink(refreshedLink); |
| activity?.AddEvent("cloudfetch.url_refreshed_before_download", [ |
| new("offset", refreshedLink.StartRowOffset) |
| ]); |
| } |
| } |
| |
| // Acquire a download slot |
| await _downloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); |
| |
| // Start the download task |
| Task downloadTask = DownloadFileAsync(downloadResult, cancellationToken) |
| .ContinueWith(t => |
| { |
| // Release the download slot |
| _downloadSemaphore.Release(); |
| |
| // Remove the task from the dictionary |
| downloadTasks.TryRemove(t, out _); |
| |
| // Handle any exceptions |
| if (t.IsFaulted) |
| { |
| Exception ex = t.Exception?.InnerException ?? new Exception("Unknown error"); |
| string sanitizedUrl = SanitizeUrl(downloadResult.Link.FileLink); |
| activity?.AddException(ex, [ |
| new("error.context", "cloudfetch.download_failed"), |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", sanitizedUrl) |
| ]); |
| |
| // Set the download as failed |
| downloadResult.SetFailed(ex); |
| failedDownloads++; |
| |
| // Set the error state to stop the download process |
| SetError(ex, activity); |
| |
| // Signal that we should stop processing downloads |
| downloadTaskCompletionSource.TrySetException(ex); |
| } |
| else if (!t.IsFaulted && !t.IsCanceled) |
| { |
| successfulDownloads++; |
| totalBytes += downloadResult.Size; |
| } |
| }, cancellationToken); |
| |
| // Add the task to the dictionary |
| downloadTasks[downloadTask] = downloadResult; |
| |
| // Add the result to the result queue add the result here to assure the download sequence. |
| _resultQueue.Add(downloadResult, cancellationToken); |
| |
| // If there's an error, stop processing more downloads |
| if (HasError) |
| { |
| break; |
| } |
| } |
| } |
| catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) |
| { |
| // Expected when cancellation is requested |
| activity?.AddEvent("cloudfetch.download_cancelled"); |
| } |
| catch (Exception ex) |
| { |
| activity?.AddException(ex, [new("error.context", "cloudfetch.download_loop")]); |
| SetError(ex, activity); |
| } |
| finally |
| { |
| overallStopwatch.Stop(); |
| |
| activity?.AddEvent("cloudfetch.download_summary", [ |
| new("total_files", totalFiles), |
| new("successful_downloads", successfulDownloads), |
| new("failed_downloads", failedDownloads), |
| new("total_bytes", totalBytes), |
| new("total_mb", totalBytes / 1024.0 / 1024.0), |
| new("total_time_ms", overallStopwatch.ElapsedMilliseconds), |
| new("total_time_sec", overallStopwatch.ElapsedMilliseconds / 1000.0) |
| ]); |
| |
| // If there's an error, add the error to the result queue |
| if (HasError) |
| { |
| CompleteWithError(activity); |
| } |
| } |
| }); |
| } |
| |
| private async Task DownloadFileAsync(IDownloadResult downloadResult, CancellationToken cancellationToken) |
| { |
| await this.TraceActivityAsync(async activity => |
| { |
| string url = downloadResult.Link.FileLink; |
| string sanitizedUrl = SanitizeUrl(downloadResult.Link.FileLink); |
| byte[]? fileData = null; |
| |
| // Use the size directly from the download result |
| long size = downloadResult.Size; |
| |
| // Add tags to the Activity for filtering/searching |
| activity?.SetTag("cloudfetch.offset", downloadResult.Link.StartRowOffset); |
| activity?.SetTag("cloudfetch.sanitized_url", sanitizedUrl); |
| activity?.SetTag("cloudfetch.expected_size_bytes", size); |
| |
| // Create a stopwatch to track download time |
| var stopwatch = Stopwatch.StartNew(); |
| |
| // Log download start |
| activity?.AddEvent("cloudfetch.download_start", [ |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", sanitizedUrl), |
| new("expected_size_bytes", size), |
| new("expected_size_kb", size / 1024.0) |
| ]); |
| |
| // Acquire memory before downloading |
| await _memoryManager.AcquireMemoryAsync(size, cancellationToken).ConfigureAwait(false); |
| |
| // Retry logic for downloading files |
| for (int retry = 0; retry < _maxRetries; retry++) |
| { |
| try |
| { |
| // Download the file directly |
| using HttpResponseMessage response = await _httpClient.GetAsync( |
| url, |
| HttpCompletionOption.ResponseHeadersRead, |
| cancellationToken).ConfigureAwait(false); |
| |
| // Check if the response indicates an expired URL (typically 403 or 401) |
| if (response.StatusCode == System.Net.HttpStatusCode.Forbidden || |
| response.StatusCode == System.Net.HttpStatusCode.Unauthorized) |
| { |
| // If we've already tried refreshing too many times, fail |
| if (downloadResult.RefreshAttempts >= _maxUrlRefreshAttempts) |
| { |
| throw new InvalidOperationException($"Failed to download file after {downloadResult.RefreshAttempts} URL refresh attempts."); |
| } |
| |
| // Try to refresh the URL |
| var refreshedLink = await _resultFetcher.GetUrlAsync(downloadResult.Link.StartRowOffset, cancellationToken); |
| if (refreshedLink != null) |
| { |
| // Update the download result with the refreshed link |
| downloadResult.UpdateWithRefreshedLink(refreshedLink); |
| url = refreshedLink.FileLink; |
| sanitizedUrl = SanitizeUrl(url); |
| |
| activity?.AddEvent("cloudfetch.url_refreshed_after_auth_error", [ |
| new("offset", refreshedLink.StartRowOffset), |
| new("sanitized_url", sanitizedUrl) |
| ]); |
| |
| // Continue to the next retry attempt with the refreshed URL |
| continue; |
| } |
| else |
| { |
| // If refresh failed, throw an exception |
| throw new InvalidOperationException("Failed to refresh expired URL."); |
| } |
| } |
| |
| response.EnsureSuccessStatusCode(); |
| |
| // Log the download size if available from response headers |
| long? contentLength = response.Content.Headers.ContentLength; |
| if (contentLength.HasValue && contentLength.Value > 0) |
| { |
| activity?.AddEvent("cloudfetch.content_length", [ |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", sanitizedUrl), |
| new("content_length_bytes", contentLength.Value), |
| new("content_length_mb", contentLength.Value / 1024.0 / 1024.0) |
| ]); |
| } |
| |
| // Read the file data |
| fileData = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); |
| break; // Success, exit retry loop |
| } |
| catch (Exception ex) when (retry < _maxRetries - 1 && !cancellationToken.IsCancellationRequested) |
| { |
| // Log the error and retry |
| activity?.AddException(ex, [ |
| new("error.context", "cloudfetch.download_retry"), |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", SanitizeUrl(url)), |
| new("attempt", retry + 1), |
| new("max_retries", _maxRetries) |
| ]); |
| |
| await Task.Delay(_retryDelayMs * (retry + 1), cancellationToken).ConfigureAwait(false); |
| } |
| } |
| |
| if (fileData == null) |
| { |
| stopwatch.Stop(); |
| activity?.AddEvent("cloudfetch.download_failed_all_retries", [ |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", sanitizedUrl), |
| new("max_retries", _maxRetries), |
| new("elapsed_time_ms", stopwatch.ElapsedMilliseconds) |
| ]); |
| |
| // Release the memory we acquired |
| _memoryManager.ReleaseMemory(size); |
| throw new InvalidOperationException($"Failed to download file from {url} after {_maxRetries} attempts."); |
| } |
| |
| // Process the downloaded file data |
| MemoryStream dataStream; |
| long actualSize = fileData.Length; |
| |
| // If the data is LZ4 compressed, decompress it |
| if (_isLz4Compressed) |
| { |
| try |
| { |
| var decompressStopwatch = Stopwatch.StartNew(); |
| |
| // Use shared Lz4Utilities for decompression (consolidates logic with non-CloudFetch path) |
| var (buffer, length) = await Lz4Utilities.DecompressLz4Async( |
| fileData, |
| cancellationToken).ConfigureAwait(false); |
| |
| // Create the dataStream from the decompressed buffer |
| dataStream = new MemoryStream(buffer, 0, length, writable: false, publiclyVisible: true); |
| dataStream.Position = 0; |
| decompressStopwatch.Stop(); |
| |
| // Calculate throughput metrics |
| double compressionRatio = (double)dataStream.Length / actualSize; |
| |
| activity?.AddEvent("cloudfetch.decompression_complete", [ |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", sanitizedUrl), |
| new("decompression_time_ms", decompressStopwatch.ElapsedMilliseconds), |
| new("compressed_size_bytes", actualSize), |
| new("compressed_size_kb", actualSize / 1024.0), |
| new("decompressed_size_bytes", dataStream.Length), |
| new("decompressed_size_kb", dataStream.Length / 1024.0), |
| new("compression_ratio", compressionRatio) |
| ]); |
| |
| actualSize = dataStream.Length; |
| } |
| catch (Exception ex) |
| { |
| stopwatch.Stop(); |
| activity?.AddException(ex, [ |
| new("error.context", "cloudfetch.decompression"), |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", sanitizedUrl), |
| new("elapsed_time_ms", stopwatch.ElapsedMilliseconds) |
| ]); |
| |
| // Release the memory we acquired |
| _memoryManager.ReleaseMemory(size); |
| throw new InvalidOperationException($"Error decompressing data: {ex.Message}", ex); |
| } |
| } |
| else |
| { |
| dataStream = new MemoryStream(fileData); |
| } |
| |
| // Stop the stopwatch and log download completion |
| stopwatch.Stop(); |
| double throughputMBps = (actualSize / 1024.0 / 1024.0) / (stopwatch.ElapsedMilliseconds / 1000.0); |
| activity?.AddEvent("cloudfetch.download_complete", [ |
| new("offset", downloadResult.Link.StartRowOffset), |
| new("sanitized_url", sanitizedUrl), |
| new("actual_size_bytes", actualSize), |
| new("actual_size_kb", actualSize / 1024.0), |
| new("latency_ms", stopwatch.ElapsedMilliseconds), |
| new("throughput_mbps", throughputMBps) |
| ]); |
| |
| // Set the download as completed with the original size |
| downloadResult.SetCompleted(dataStream, size); |
| }, activityName: "DownloadFile"); |
| } |
| |
| private void SetError(Exception ex, Activity? activity = null) |
| { |
| lock (_errorLock) |
| { |
| if (_error == null) |
| { |
| activity?.AddException(ex, [new("error.context", "cloudfetch.error_state_set")]); |
| _error = ex; |
| } |
| } |
| } |
| |
| private void CompleteWithError(Activity? activity = null) |
| { |
| try |
| { |
| // Mark the result queue as completed to prevent further additions |
| _resultQueue.CompleteAdding(); |
| |
| // Mark the download as completed with error |
| _isCompleted = true; |
| } |
| catch (Exception ex) |
| { |
| activity?.AddException(ex, [new("error.context", "cloudfetch.complete_with_error_failed")]); |
| } |
| } |
| |
| // Helper method to sanitize URLs for logging (to avoid exposing sensitive information) |
| private string SanitizeUrl(string url) |
| { |
| try |
| { |
| var uri = new Uri(url); |
| return $"{uri.Scheme}://{uri.Host}/{Path.GetFileName(uri.LocalPath)}"; |
| } |
| catch |
| { |
| // If URL parsing fails, return a generic identifier |
| return "cloud-storage-url"; |
| } |
| } |
| |
| // IActivityTracer implementation - delegates to statement |
| ActivityTrace IActivityTracer.Trace => _statement.Trace; |
| |
| string? IActivityTracer.TraceParent => _statement.TraceParent; |
| |
| public string AssemblyVersion => _statement.AssemblyVersion; |
| |
| public string AssemblyName => _statement.AssemblyName; |
| } |
| } |