Skip to content

Commit c40e119

Browse files
lukekimkrinart
andauthored
feat: add refresh options for on-demand dataset refresh (#21)
* feat: add refresh options for on-demand dataset refresh `RefreshDatasetAsync` could only trigger a dataset refresh using the dataset's configured settings. Every other Spice SDK (Go, Python, Rust, Java, TypeScript) accepts per-refresh overrides, so .NET callers had no way to run a filtered or append-mode refresh without editing the Spicepod. Add a `RefreshOptions` type carrying the three overrides the runtime accepts on `POST /v1/datasets/{name}/acceleration/refresh`: - `RefreshSql` -> `refresh_sql` - `RefreshMode` -> `refresh_mode` (`Full` / `Append`) - `MaxJitter` -> `refresh_jitter_max` (e.g. "10s", "1500ms") Unset options are omitted from the body so the runtime falls back to the dataset configuration, matching the other SDKs. The existing single-argument `RefreshDatasetAsync(string)` overload is unchanged, so this is source and binary compatible. It now posts `{}` with `application/json` instead of an empty body, which is what the other SDKs send and what the runtime documents. netstandard2.0 gains an explicit `System.Text.Json` reference; it is in-box for .NET 8.0+. * fix: surface why authentication failed instead of a trailer error AuthenticateAsync passed stream.GetTrailers() as an argument, so trailers were read eagerly — before the handshake had completed. gRPC only makes trailers available once a call finishes, so any handshake that fails throws System.InvalidOperationException: Can't get the call trailers because the call has not completed successfully. which replaces the gRPC status describing the actual problem. Every integration test in CI reports this, and it says nothing about what to fix. Now the token is read from the response headers first, trailers are consulted only if the headers carry none, and a failure to read them is caught rather than propagated. The resulting SpiceException names the likely cause and carries the originating RpcException as InnerException. Reproduced against a TLS endpoint that is not a Flight service: before: InvalidOperationException: Can't get the call trailers ... after: SpiceException: Failed to authenticate: the runtime returned no authorization token. Check that the API key is valid for this endpoint. * fix: preserve trailer-read failure reason when handshake never completes Sets failure to the InvalidOperationException so the auth error message reflects that the handshake did not complete, instead of falling back to the generic "no authorization token" message. Adds unit tests for the failure-description logic. * fix: address review comments and merge trunk + auth fix - Fix ArgumentException.ThrowIfNullOrWhiteSpace polyfill to throw ArgumentNullException for null (matching NET8+ behavior), not just ArgumentException; update docs and add tests. - Validate MaxJitter in RefreshOptions.ToJson so a negative value set via the object initializer is rejected, not just via WithMaxJitter. - Update README refresh options table to show nullable types. - Merge trunk (SearchAsync) and the surface-auth-failure fix, which was causing the integration test failures seen in CI on this branch. --------- Co-authored-by: Viktor Yershov <viktor@spice.ai>
1 parent e032aae commit c40e119

9 files changed

Lines changed: 672 additions & 6 deletions

File tree

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,45 @@ using var client = new SpiceClientBuilder().Build();
140140
await client.RefreshDatasetAsync("my_dataset");
141141
```
142142

143+
##### Refresh Options
144+
145+
Override the dataset's configured refresh settings for a single refresh by passing
146+
`RefreshOptions`. Any option left unset falls back to the dataset's Spicepod configuration.
147+
148+
```csharp
149+
using Spice;
150+
using Spice.Datasets;
151+
152+
using var client = new SpiceClientBuilder().Build();
153+
154+
await client.RefreshDatasetAsync("taxi_trips", new RefreshOptions()
155+
.WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount > 10.0")
156+
.WithRefreshMode(RefreshMode.Append)
157+
.WithMaxJitter(TimeSpan.FromSeconds(10)));
158+
```
159+
160+
Object initializer syntax works too:
161+
162+
```csharp
163+
await client.RefreshDatasetAsync("taxi_trips", new RefreshOptions
164+
{
165+
RefreshSql = "SELECT * FROM taxi_trips WHERE tip_amount > 10.0",
166+
RefreshMode = RefreshMode.Append,
167+
MaxJitter = TimeSpan.FromSeconds(10),
168+
});
169+
```
170+
171+
| Option | Type | Description |
172+
| --- | --- | --- |
173+
| `RefreshSql` | `string?` | The SQL statement used for this refresh. Defaults to the dataset's `refresh_sql`. |
174+
| `RefreshMode` | `RefreshMode?` | `Full` replaces the accelerated data; `Append` adds newly returned rows. Defaults to the dataset's `refresh_mode`. |
175+
| `MaxJitter` | `TimeSpan?` | Maximum jitter added before the refresh starts. Defaults to the dataset's `refresh_jitter_max`. |
176+
177+
All options are optional — leave any of them unset (`null`) to fall back to the dataset's configured value.
178+
179+
> **Note**: On-demand refreshes apply to the `full` and `append` refresh modes. Datasets accelerated with
180+
> `changes` mode are kept up to date by change data capture and are not refreshed through this API.
181+
143182
#### Health and Readiness
144183

145184
`IsSpiceHealthyAsync` reports whether the runtime process is up. `IsSpiceReadyAsync` reports
@@ -253,12 +292,17 @@ var data = await client.Query(
253292

254293
```csharp
255294
using Spice;
295+
using Spice.Datasets;
256296

257297
using var client = new SpiceClientBuilder()
258298
.WithSpiceCloud("API_KEY")
259299
.Build();
260300

261301
await client.RefreshDatasetAsync("my_dataset");
302+
303+
// Or with refresh overrides for this refresh only
304+
await client.RefreshDatasetAsync("my_dataset", new RefreshOptions()
305+
.WithRefreshMode(RefreshMode.Append));
262306
```
263307

264308
#### Search
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
Copyright 2024 The Spice.ai OSS Authors
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in all
12+
copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
SOFTWARE.
21+
*/
22+
23+
using System.Globalization;
24+
using System.Text.Json;
25+
26+
namespace Spice.Datasets;
27+
28+
/// <summary>
29+
/// The refresh mode to use for a single on-demand dataset refresh.
30+
/// </summary>
31+
/// <remarks>
32+
/// On-demand refreshes apply to the <c>full</c> and <c>append</c> refresh modes only.
33+
/// Datasets accelerated with <c>changes</c> mode are kept up to date by change data
34+
/// capture and are not refreshed through this API.
35+
/// </remarks>
36+
public enum RefreshMode
37+
{
38+
/// <summary>
39+
/// Replace the accelerated data with the full result of the refresh query.
40+
/// </summary>
41+
Full,
42+
43+
/// <summary>
44+
/// Append newly returned rows to the accelerated data.
45+
/// </summary>
46+
Append,
47+
}
48+
49+
/// <summary>
50+
/// Optional overrides for a single on-demand dataset acceleration refresh.
51+
/// </summary>
52+
/// <remarks>
53+
/// Every option is optional. Any option left unset falls back to the value configured
54+
/// for the dataset in the Spicepod. An instance with no options set requests a refresh
55+
/// using the dataset's existing configuration.
56+
/// </remarks>
57+
/// <example>
58+
/// <code>
59+
/// var options = new RefreshOptions()
60+
/// .WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount &gt; 10.0")
61+
/// .WithRefreshMode(RefreshMode.Append)
62+
/// .WithMaxJitter(TimeSpan.FromSeconds(10));
63+
///
64+
/// await client.RefreshDatasetAsync("taxi_trips", options);
65+
/// </code>
66+
/// </example>
67+
public sealed class RefreshOptions
68+
{
69+
/// <summary>
70+
/// The SQL statement used for this refresh. Defaults to the <c>refresh_sql</c>
71+
/// configured for the dataset, if any.
72+
/// </summary>
73+
public string? RefreshSql { get; set; }
74+
75+
/// <summary>
76+
/// The refresh mode to use for this refresh. Defaults to the <c>refresh_mode</c>
77+
/// configured for the dataset, or <see cref="Datasets.RefreshMode.Full"/>.
78+
/// </summary>
79+
public RefreshMode? RefreshMode { get; set; }
80+
81+
/// <summary>
82+
/// The maximum amount of jitter to add before starting this refresh. Defaults to the
83+
/// <c>refresh_jitter_max</c> configured for the dataset, or 10% of the refresh check interval.
84+
/// </summary>
85+
public TimeSpan? MaxJitter { get; set; }
86+
87+
/// <summary>
88+
/// Sets the SQL statement used for this refresh.
89+
/// </summary>
90+
/// <param name="refreshSql">The refresh SQL statement.</param>
91+
/// <returns>The current instance of <see cref="RefreshOptions"/> for method chaining.</returns>
92+
/// <exception cref="System.ArgumentNullException">Thrown when refreshSql is null.</exception>
93+
/// <exception cref="System.ArgumentException">Thrown when refreshSql is empty or whitespace.</exception>
94+
public RefreshOptions WithRefreshSql(string refreshSql)
95+
{
96+
#if NET8_0_OR_GREATER
97+
ArgumentException.ThrowIfNullOrWhiteSpace(refreshSql);
98+
#else
99+
ThrowHelper.ThrowIfNullOrWhiteSpace(refreshSql, nameof(refreshSql));
100+
#endif
101+
RefreshSql = refreshSql;
102+
return this;
103+
}
104+
105+
/// <summary>
106+
/// Sets the refresh mode to use for this refresh.
107+
/// </summary>
108+
/// <param name="refreshMode">The refresh mode.</param>
109+
/// <returns>The current instance of <see cref="RefreshOptions"/> for method chaining.</returns>
110+
public RefreshOptions WithRefreshMode(RefreshMode refreshMode)
111+
{
112+
RefreshMode = refreshMode;
113+
return this;
114+
}
115+
116+
/// <summary>
117+
/// Sets the maximum amount of jitter to add before starting this refresh.
118+
/// </summary>
119+
/// <param name="maxJitter">The maximum jitter. Must not be negative.</param>
120+
/// <returns>The current instance of <see cref="RefreshOptions"/> for method chaining.</returns>
121+
/// <exception cref="System.ArgumentOutOfRangeException">Thrown when maxJitter is negative.</exception>
122+
public RefreshOptions WithMaxJitter(TimeSpan maxJitter)
123+
{
124+
if (maxJitter < TimeSpan.Zero)
125+
{
126+
throw new ArgumentOutOfRangeException(nameof(maxJitter), maxJitter, "maxJitter must not be negative.");
127+
}
128+
129+
MaxJitter = maxJitter;
130+
return this;
131+
}
132+
133+
/// <summary>
134+
/// Serializes the options to the JSON request body expected by the Spice runtime.
135+
/// Unset options are omitted so the runtime falls back to the dataset configuration.
136+
/// </summary>
137+
internal string ToJson()
138+
{
139+
var body = new Dictionary<string, string>(StringComparer.Ordinal);
140+
141+
if (!string.IsNullOrWhiteSpace(RefreshSql))
142+
{
143+
body["refresh_sql"] = RefreshSql!;
144+
}
145+
146+
if (RefreshMode.HasValue)
147+
{
148+
body["refresh_mode"] = ToWireValue(RefreshMode.Value);
149+
}
150+
151+
if (MaxJitter.HasValue)
152+
{
153+
if (MaxJitter.Value < TimeSpan.Zero)
154+
{
155+
throw new ArgumentOutOfRangeException(nameof(MaxJitter), MaxJitter.Value, "MaxJitter must not be negative.");
156+
}
157+
158+
body["refresh_jitter_max"] = FormatDuration(MaxJitter.Value);
159+
}
160+
161+
return JsonSerializer.Serialize(body);
162+
}
163+
164+
/// <summary>
165+
/// Maps a <see cref="Datasets.RefreshMode"/> to the value understood by the runtime.
166+
/// </summary>
167+
internal static string ToWireValue(RefreshMode refreshMode) => refreshMode switch
168+
{
169+
Datasets.RefreshMode.Full => "full",
170+
Datasets.RefreshMode.Append => "append",
171+
_ => throw new ArgumentOutOfRangeException(nameof(refreshMode), refreshMode, "Unsupported refresh mode."),
172+
};
173+
174+
/// <summary>
175+
/// Formats a <see cref="TimeSpan"/> as a duration string the runtime can parse (for example "10s" or "1500ms").
176+
/// </summary>
177+
internal static string FormatDuration(TimeSpan value)
178+
{
179+
var totalMilliseconds = (long)value.TotalMilliseconds;
180+
181+
return totalMilliseconds % 1000 == 0
182+
? string.Concat((totalMilliseconds / 1000).ToString(CultureInfo.InvariantCulture), "s")
183+
: string.Concat(totalMilliseconds.ToString(CultureInfo.InvariantCulture), "ms");
184+
}
185+
}

Spice/src/Http/ISpiceHttpClient.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2020
SOFTWARE.
2121
*/
2222

23+
using Spice.Datasets;
2324
using Spice.Search;
2425

2526
namespace Spice.Http;
@@ -47,6 +48,17 @@ public interface ISpiceHttpClient : IDisposable
4748
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
4849
Task RefreshDatasetAsync(string datasetName);
4950

51+
/// <summary>
52+
/// Refreshes a dataset in the Spice runtime, overriding the dataset's configured
53+
/// refresh settings for this refresh only.
54+
/// </summary>
55+
/// <param name="datasetName">The name of the dataset to refresh</param>
56+
/// <param name="options">Overrides for this refresh, or null to use the dataset configuration</param>
57+
/// <returns>A task representing the asynchronous operation</returns>
58+
/// <exception cref="System.ArgumentException">Thrown when datasetName is null or empty</exception>
59+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
60+
Task RefreshDatasetAsync(string datasetName, RefreshOptions? options);
61+
5062
/// <summary>
5163
/// Checks whether the Spice runtime is healthy by calling the <c>/health</c> endpoint.
5264
/// </summary>

Spice/src/Http/SpiceHttpClient.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2525
using System.Text.Json;
2626
using Spice.Auth;
2727
using Spice.Common;
28+
using Spice.Datasets;
2829
using Spice.Search;
2930

3031
namespace Spice.Http;
@@ -123,7 +124,18 @@ public Task<string> QueryAsync(string sql)
123124
/// <returns>A task representing the asynchronous operation</returns>
124125
/// <exception cref="System.ArgumentException">Thrown when datasetName is null or empty</exception>
125126
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
126-
public async Task RefreshDatasetAsync(string datasetName)
127+
public Task RefreshDatasetAsync(string datasetName) => RefreshDatasetAsync(datasetName, null);
128+
129+
/// <summary>
130+
/// Refreshes a dataset in the Spice runtime, overriding the dataset's configured
131+
/// refresh settings for this refresh only.
132+
/// </summary>
133+
/// <param name="datasetName">The name of the dataset to refresh</param>
134+
/// <param name="options">Overrides for this refresh, or null to use the dataset configuration</param>
135+
/// <returns>A task representing the asynchronous operation</returns>
136+
/// <exception cref="System.ArgumentException">Thrown when datasetName is null or empty</exception>
137+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
138+
public async Task RefreshDatasetAsync(string datasetName, RefreshOptions? options)
127139
{
128140
#if NET8_0_OR_GREATER
129141
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -134,7 +146,8 @@ public async Task RefreshDatasetAsync(string datasetName)
134146
#endif
135147

136148
var url = $"{_httpAddress}/v1/datasets/{datasetName}/acceleration/refresh";
137-
var response = await _httpClient.PostAsync(url, null).ConfigureAwait(false);
149+
using var content = new StringContent(options?.ToJson() ?? "{}", Encoding.UTF8, "application/json");
150+
var response = await _httpClient.PostAsync(url, content).ConfigureAwait(false);
138151
response.EnsureSuccessStatusCode();
139152
}
140153

Spice/src/Polyfills/ThrowHelper.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@ public static void ThrowIfNull(object? argument, string? paramName = null)
3434

3535
public static void ThrowIfNullOrWhiteSpace(string? argument, string? paramName = null)
3636
{
37+
if (argument is null)
38+
{
39+
throw new ArgumentNullException(paramName);
40+
}
41+
3742
if (string.IsNullOrWhiteSpace(argument))
3843
{
39-
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
44+
throw new ArgumentException("Value cannot be empty or whitespace.", paramName);
4045
}
4146
}
4247

Spice/src/SpiceClient.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2424
using Apache.Arrow.Ipc;
2525
using Spice.Adbc;
2626
using Spice.Config;
27+
using Spice.Datasets;
2728
using Spice.Flight;
2829
using Spice.Http;
2930
using Spice.Search;
@@ -172,13 +173,32 @@ public Task<FlightClientRecordBatchStreamReader> Query(string sql)
172173
}
173174

174175
/// <summary>
175-
/// Refreshes a dataset in the Spice runtime.
176+
/// Refreshes a dataset in the Spice runtime using the dataset's configured refresh settings.
176177
/// </summary>
177178
/// <param name="datasetName">The name of the dataset to refresh</param>
178179
/// <returns>A task representing the asynchronous operation</returns>
179180
/// <exception cref="System.ArgumentException">Thrown when datasetName is null or empty</exception>
180181
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
181-
public Task RefreshDatasetAsync(string datasetName)
182+
public Task RefreshDatasetAsync(string datasetName) => RefreshDatasetAsync(datasetName, null);
183+
184+
/// <summary>
185+
/// Refreshes a dataset in the Spice runtime, overriding the dataset's configured
186+
/// refresh settings for this refresh only.
187+
/// </summary>
188+
/// <example>
189+
/// <code>
190+
/// // Refresh only the recent rows, appending them to the accelerated data.
191+
/// await client.RefreshDatasetAsync("taxi_trips", new RefreshOptions()
192+
/// .WithRefreshSql("SELECT * FROM taxi_trips WHERE tip_amount &gt; 10.0")
193+
/// .WithRefreshMode(RefreshMode.Append));
194+
/// </code>
195+
/// </example>
196+
/// <param name="datasetName">The name of the dataset to refresh</param>
197+
/// <param name="options">Overrides for this refresh, or null to use the dataset configuration</param>
198+
/// <returns>A task representing the asynchronous operation</returns>
199+
/// <exception cref="System.ArgumentException">Thrown when datasetName is null or empty</exception>
200+
/// <exception cref="System.Net.Http.HttpRequestException">Thrown when the HTTP request fails</exception>
201+
public Task RefreshDatasetAsync(string datasetName, RefreshOptions? options)
182202
{
183203
#if NET8_0_OR_GREATER
184204
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -187,7 +207,7 @@ public Task RefreshDatasetAsync(string datasetName)
187207
#endif
188208
if (HttpClient == null) throw new InvalidOperationException("HttpClient not initialized");
189209

190-
return HttpClient.RefreshDatasetAsync(datasetName);
210+
return HttpClient.RefreshDatasetAsync(datasetName, options);
191211
}
192212

193213
/// <summary>

0 commit comments

Comments
 (0)