Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Spice/src/Http/ISpiceHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
*/

using Spice.Datasets;
using Spice.Query;
using Spice.Search;

namespace Spice.Http;
Expand Down Expand Up @@ -85,4 +86,22 @@ public interface ISpiceHttpClient : IDisposable
/// <exception cref="System.ArgumentException">Thrown when the search text is null or empty</exception>
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Lists the synchronous queries currently running, by calling <c>GET /v1/sql/active</c>.
/// </summary>
/// <param name="cancellationToken">Token to cancel the request</param>
/// <returns>The running queries, empty when none are running</returns>
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
Task<IReadOnlyList<ActiveQuery>> ListActiveQueriesAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Cancels a running synchronous query by ID, by calling <c>POST /v1/sql/{id}/cancel</c>.
/// </summary>
/// <param name="queryId">The query ID, from <see cref="ListActiveQueriesAsync"/></param>
/// <param name="cancellationToken">Token to cancel the request</param>
/// <returns>A task representing the asynchronous operation</returns>
/// <exception cref="System.ArgumentException">Thrown when queryId is null, empty, or not a valid UUID</exception>
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
Task CancelActiveQueryAsync(string queryId, CancellationToken cancellationToken = default);
}
106 changes: 106 additions & 0 deletions Spice/src/Http/SpiceHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using Spice.Auth;
using Spice.Common;
using Spice.Datasets;
using Spice.Query;
using Spice.Search;

namespace Spice.Http;
Expand Down Expand Up @@ -282,6 +283,111 @@ public async Task<SearchResponse> SearchAsync(SearchRequest request, Cancellatio
return JsonSerializer.Deserialize<SearchResponse>(body, SearchJsonOptions) ?? new SearchResponse();
}

/// <summary>
/// Lists the synchronous queries currently running, by calling <c>GET /v1/sql/active</c>.
/// </summary>
/// <param name="cancellationToken">Token to cancel the request</param>
/// <returns>The running queries, empty when none are running</returns>
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
public async Task<IReadOnlyList<ActiveQuery>> ListActiveQueriesAsync(CancellationToken cancellationToken = default)
{
#if NET8_0_OR_GREATER
ObjectDisposedException.ThrowIf(_disposed, this);
#else
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
#endif

var url = $"{_httpAddress}/v1/sql/active";
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);

#if NET8_0_OR_GREATER
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif

if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
throw new HttpRequestException(
"Listing active queries failed: the configured API key does not allow listing queries, use a key with write access.");
}
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"GET {url} failed with status {(int)response.StatusCode} ({response.StatusCode}): {ExtractErrorMessage(body)}");
}

var decoded = JsonSerializer.Deserialize<ActiveQueriesResponse>(body);
return decoded?.Queries ?? new List<ActiveQuery>();
}

/// <summary>
/// Cancels a running synchronous query by ID, by calling <c>POST /v1/sql/{id}/cancel</c>.
/// </summary>
/// <param name="queryId">The query ID, from <see cref="ListActiveQueriesAsync"/></param>
/// <param name="cancellationToken">Token to cancel the request</param>
/// <returns>A task representing the asynchronous operation</returns>
/// <exception cref="System.ArgumentException">Thrown when queryId is null, empty, or not a valid UUID</exception>
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
public async Task CancelActiveQueryAsync(string queryId, CancellationToken cancellationToken = default)
{
#if NET8_0_OR_GREATER
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(queryId);
#else
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
ThrowHelper.ThrowIfNullOrWhiteSpace(queryId, nameof(queryId));
#endif

// queryId is caller input that reaches the runtime as a URL path segment. Reject
// anything that is not a canonical UUID here, rather than building a path from it:
// a value like "." or ".." is unreserved and survives escaping, so a proxy or server
// that resolves dot segments could route this POST somewhere the caller never named.
if (!IsValidQueryId(queryId))
{
throw new ArgumentException(
$"Query ID \"{queryId}\" is not a valid UUID. Use the QueryId from {nameof(ListActiveQueriesAsync)}.",
nameof(queryId));
}

var url = $"{_httpAddress}/v1/sql/{Uri.EscapeDataString(queryId)}/cancel";
using var response = await _httpClient.PostAsync(url, content: null, cancellationToken).ConfigureAwait(false);

#if NET8_0_OR_GREATER
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif

switch (response.StatusCode)
{
case System.Net.HttpStatusCode.OK:
return;
case System.Net.HttpStatusCode.BadRequest:
throw new ArgumentException(
$"Query ID \"{queryId}\" is not a valid UUID. Use the QueryId from {nameof(ListActiveQueriesAsync)}.",
nameof(queryId));
case System.Net.HttpStatusCode.Forbidden:
throw new HttpRequestException(
"Cancelling the query failed: the configured API key does not allow cancelling queries, use a key with write access.");
case System.Net.HttpStatusCode.NotFound:
throw new HttpRequestException(
$"No active query \"{queryId}\" found: it may have already finished, or it was submitted under a different API key.");
default:
throw new HttpRequestException(
$"POST {url} failed with status {(int)response.StatusCode} ({response.StatusCode}): {ExtractErrorMessage(body)}");
}
}

/// <summary>
/// Reports whether queryId has the exact canonical hyphenated shape the runtime parses
/// as a UUID (36 characters, lowercase or uppercase hex, no surrounding whitespace or
/// braces) — the IDs this SDK cancels always come from <see cref="ListActiveQueriesAsync"/>,
/// so anything looser cannot name a running query.
/// </summary>
private static bool IsValidQueryId(string queryId) =>
queryId.Length == 36 && Guid.TryParseExact(queryId, "D", out _);

/// <summary>
/// Pulls the runtime's error message out of a failed response body, falling back to
/// the raw body when it is not the expected shape.
Expand Down
93 changes: 93 additions & 0 deletions Spice/src/Query/ActiveQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
Copyright 2024 The Spice.ai OSS Authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

using System.Text.Json.Serialization;

namespace Spice.Query;

/// <summary>
/// A synchronous query currently running on the Spice runtime.
/// </summary>
/// <remarks>
/// Synchronous queries are the ones started by <see cref="SpiceClient.Query"/>,
/// <see cref="SpiceClient.QueryWithParams"/>, FlightSQL, NSQL, and search. The runtime does not
/// return a query's ID to the client that submitted it, so <see cref="SpiceClient.ListActiveQueriesAsync"/>
/// is how to find the ID that <see cref="SpiceClient.CancelActiveQueryAsync"/> needs.
/// </remarks>
public class ActiveQuery
{
/// <summary>
/// The ID the runtime assigned to this query. Pass this to
/// <see cref="SpiceClient.CancelActiveQueryAsync"/> to cancel it.
/// </summary>
[JsonPropertyName("query_id")]
public string QueryId { get; set; } = string.Empty;

/// <summary>
/// The protocol the query arrived on: <c>"http"</c>, <c>"flight"</c>, <c>"flightsql"</c>, or
/// <c>"internal"</c>.
/// </summary>
[JsonPropertyName("protocol")]
public string Protocol { get; set; } = string.Empty;

/// <summary>
/// The query's SQL, truncated by the runtime for display.
/// </summary>
[JsonPropertyName("sql_preview")]
public string SqlPreview { get; set; } = string.Empty;

/// <summary>
/// When the query started, in milliseconds since the Unix epoch.
/// </summary>
[JsonPropertyName("started_at_ms")]
public long StartedAtMs { get; set; }

/// <summary>
/// The query's start time.
/// </summary>
[JsonIgnore]
public DateTimeOffset StartedAt => DateTimeOffset.FromUnixTimeMilliseconds(StartedAtMs);
}

/// <summary>
/// The wire envelope returned by <c>GET /v1/sql/active</c>.
/// </summary>
internal sealed class ActiveQueriesResponse
{
[JsonPropertyName("queries")]
public List<ActiveQuery> Queries { get; set; } = new();

[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
}

/// <summary>
/// The wire envelope returned by <c>POST /v1/sql/{id}/cancel</c>.
/// </summary>
internal sealed class CancelActiveQueryResponse
{
[JsonPropertyName("query_id")]
public string QueryId { get; set; } = string.Empty;

[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
}
67 changes: 67 additions & 0 deletions Spice/src/SpiceClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using Spice.Datasets;
using Spice.Flight;
using Spice.Http;
using Spice.Query;
using Spice.Search;

namespace Spice;
Expand Down Expand Up @@ -298,6 +299,72 @@ public Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken
return HttpClient.SearchAsync(request, cancellationToken);
}

/// <summary>
/// Lists the synchronous queries currently running on the runtime.
/// </summary>
/// <remarks>
/// Synchronous queries are the ones started by <see cref="Query"/>, <see cref="QueryWithParams"/>,
/// FlightSQL, NSQL, and search. The runtime does not return a query's ID to the client that
/// submitted it, so this is how to find the ID that <see cref="CancelActiveQueryAsync"/> needs.
///
/// <para>
/// Results are scoped to the authenticated principal — an API key or a client certificate —
/// rather than to this <see cref="SpiceClient"/> instance: every client presenting the same
/// credential lists the same queries. Runtime releases up to and including v2.1.5 do not scope
/// these endpoints at all; see
/// <see href="https://github.com/spiceai/spiceai/pull/12841">spiceai/spiceai#12841</see>.
/// </para>
///
/// <para>
/// Results also cover only the one runtime instance this client's HTTP endpoint reaches, since
/// the runtime holds active queries in memory per process.
/// </para>
/// </remarks>
/// <param name="cancellationToken">Token to cancel the request</param>
/// <returns>The running queries, empty when none are running</returns>
/// <exception cref="System.InvalidOperationException">Thrown when the client is not initialized</exception>
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
public Task<IReadOnlyList<ActiveQuery>> ListActiveQueriesAsync(CancellationToken cancellationToken = default)
{
#if NET8_0_OR_GREATER
ObjectDisposedException.ThrowIf(_disposed, this);
#else
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
#endif
if (HttpClient == null) throw new InvalidOperationException("HttpClient not initialized");

return HttpClient.ListActiveQueriesAsync(cancellationToken);
}

/// <summary>
/// Cancels a running synchronous query by ID.
/// </summary>
/// <remarks>
/// <paramref name="queryId"/> comes from <see cref="ListActiveQueriesAsync"/>. Cancellation is
/// scoped to the authenticated principal, not to this <see cref="SpiceClient"/> instance: any
/// client presenting the same credential can cancel the query, while an ID outside that scope is
/// reported as not found. This reaches the one runtime instance this client's HTTP endpoint
/// resolves to, with the same runtime-version scoping caveat described on
/// <see cref="ListActiveQueriesAsync"/>.
/// </remarks>
/// <param name="queryId">The query ID, from <see cref="ListActiveQueriesAsync"/></param>
/// <param name="cancellationToken">Token to cancel the request</param>
/// <returns>A task representing the asynchronous operation</returns>
/// <exception cref="System.ArgumentException">Thrown when queryId is null, empty, or not a valid UUID</exception>
/// <exception cref="System.InvalidOperationException">Thrown when the client is not initialized</exception>
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
public Task CancelActiveQueryAsync(string queryId, CancellationToken cancellationToken = default)
{
#if NET8_0_OR_GREATER
ObjectDisposedException.ThrowIf(_disposed, this);
#else
if (_disposed) throw new ObjectDisposedException(GetType().FullName);
#endif
if (HttpClient == null) throw new InvalidOperationException("HttpClient not initialized");

return HttpClient.CancelActiveQueryAsync(queryId, cancellationToken);
}

private bool _disposed;

/// <summary>
Expand Down
Loading