Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ here rather than left to be discovered.

### Fixed

- **Re-executing an expired result now returns the rows.** `execute-query` only *starts* the
re-execution: against a live workspace it answers `PENDING` with no manifest, and the rows appear
on the ordinary query-result endpoint a moment later. The client returned that acknowledgement,
so `ReExecuteQueryAsync` produced `null` and `lakespeak export last` told the user to ask the
question again — while the warehouse work they had just paid for completed and was thrown away.
It now waits for the rows.

The contract test covering this stubbed a *completed* response, a shape Databricks does not
return, so the test agreed with the bug. The replacement is a **live** test, because a stub
cannot catch an error in the stub.

- **Release binaries are now attested.** The provenance attestation covered only the `.nupkg`
files, so `gh attestation verify` on a downloaded release binary failed — the exact command
`SECURITY.md` tells an adopter to run. The zips are now attestation subjects too.
Expand Down
5 changes: 4 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ Status: in development.
stated unknown. What remains unreached is Genie *itself* emitting a multi-chunk result: it
writes its own SQL and generally bounds it, so this needs a Genie Agent over a large table and
still cannot be forced. Covered by contract tests meanwhile.
- `QUERY_RESULT_EXPIRED` recovery — the cache expires on Databricks' schedule, hours later. No
- `QUERY_RESULT_EXPIRED` recovery — **the recovery call itself is now verified live**
(2026-08-06), which found that `execute-query` only *starts* the re-execution and the client
was returning its `PENDING` acknowledgement instead of the rows. What is still unreached is the
*expiry* that triggers it: the cache expires on Databricks' schedule, hours later. No
way to force it; covered by contract tests only.
- [x] Command reference and authentication guide

Expand Down
27 changes: 25 additions & 2 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,31 @@ The live suite in `tests/LakeSpeak.LiveIntegrationTests` reproduces all of this.
`DATABRICKS_HOST`, `DATABRICKS_TOKEN` and `LAKESPEAK_LIVE_AGENT` set:
`dotnet test -c Release --filter "Category=Live"`.

What this still does **not** exercise: the Genie full-result download endpoints, visualizations,
and `QUERY_RESULT_EXPIRED` recovery. Those remain covered by contract tests only.
What this still does **not** exercise: the Genie full-result download endpoints and visualizations.
Those remain covered by contract tests only.

## Re-executing an expired result — 2026-08-06

Exercising `execute-query` against the live workspace corrected a wire assumption that a contract
test had encoded wrongly, and with it a real defect.

| Call | Response |
|---|---|
| `POST …/attachments/{id}/execute-query` | HTTP 200, `state: PENDING`, **no manifest, no rows** |
| `GET …/attachments/{id}/query-result` moments later | `state: SUCCEEDED`, rows present |

`execute-query` only *starts* the re-execution. The client returned that first acknowledgement, so
`ReExecuteQueryAsync` produced `null` and `export last` told the user to ask the question again —
while the warehouse work they had just paid for completed and was discarded. It now polls for the
rows.

The contract test covering this stubbed a *completed* response, which Databricks does not return.
A stub cannot catch an error in the stub, which is the general lesson and the reason
`A_re_executed_query_returns_its_rows` is a **live** test rather than another fixture.

Still unreached: the `QUERY_RESULT_EXPIRED` state that triggers recovery. Databricks expires the
cache on its own schedule, hours later, and there is no way to force it — so the recovery is
verified, and the condition it recovers from is simulated.

## `chat`, verified live — 2026-08-06

Expand Down
50 changes: 47 additions & 3 deletions src/LakeSpeak.Genie/GenieClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,12 +236,50 @@ public async Task<GenieResponse> WaitForResponseAsync(
$"{Root}/{Esc(agentId)}/conversations/{Esc(conversationId)}/messages/{Esc(messageId)}/attachments/{Esc(attachmentId)}/query-result",
HttpMethod.Get, cancellationToken);

public Task<GenieQueryResult?> ReExecuteQueryAsync(
public async Task<GenieQueryResult?> ReExecuteQueryAsync(
string agentId, string conversationId, string messageId, string attachmentId,
CancellationToken cancellationToken = default)
=> FetchQueryResultAsync(
{
// execute-query only *starts* the re-execution. Observed against a live workspace it
// answers HTTP 200 with state PENDING and no manifest, and the rows appear on the
// ordinary query-result endpoint a moment later. Returning that first response hands the
// caller nothing while the warehouse work they just paid for completes and is discarded —
// which surfaced as `export last` telling people to ask the question again.
var started = await FetchQueryResultAsync(
$"{Root}/{Esc(agentId)}/conversations/{Esc(conversationId)}/messages/{Esc(messageId)}/attachments/{Esc(attachmentId)}/execute-query",
HttpMethod.Post, cancellationToken);
HttpMethod.Post,
cancellationToken).ConfigureAwait(false);

if (started is not null)
{
return started;
}

var deadline = _time.GetTimestamp();
var interval = _options.InitialPollInterval;

while (_time.GetElapsedTime(deadline) < _options.PollingTimeout)
{
await Task.Delay(interval, _time, cancellationToken).ConfigureAwait(false);

var result = await GetQueryResultAsync(
agentId, conversationId, messageId, attachmentId, cancellationToken).ConfigureAwait(false);

if (result is not null)
{
return result;
}

interval = TimeSpan.FromMilliseconds(
Math.Min(interval.TotalMilliseconds * 1.5, _options.MaxPollInterval.TotalMilliseconds));
}

// The caller gets null rather than an exception, exactly as before: a re-execution that
// never lands is a missing result, not a transport failure.
LogReExecuteTimedOut(_logger, _options.PollingTimeout.TotalSeconds);
return null;
}


public async Task SendFeedbackAsync(
string agentId,
Expand Down Expand Up @@ -741,6 +779,12 @@ private static async Task<GenieException> ToFailureAsync(
"The result is reported as truncated.")]
private static partial void LogChunkFetchLimitReached(ILogger logger, int limit);

[LoggerMessage(
EventId = 9,
Level = LogLevel.Warning,
Message = "A re-executed query did not produce a result within {Seconds}s.")]
private static partial void LogReExecuteTimedOut(ILogger logger, double seconds);

[LoggerMessage(
EventId = 7,
Level = LogLevel.Warning,
Expand Down
35 changes: 35 additions & 0 deletions tests/LakeSpeak.ContractTests/ResultCompletenessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,41 @@ public async Task An_external_links_result_is_refused_rather_than_returned_empty
ex.Message.ShouldContain("100000");
}

/// <summary>
/// Re-executing an expired result must return the rows, not the acknowledgement.
/// </summary>
/// <remarks>
/// Observed against a live workspace: <c>execute-query</c> answers HTTP 200 with state
/// <c>PENDING</c> and no manifest — it only starts the re-execution — and the rows arrive on
/// the ordinary query-result endpoint a moment later. Returning that first response gave the
/// caller nothing, so <c>export last</c> told people to ask the question again while the
/// warehouse work they had just paid for completed and was thrown away.
/// </remarks>
[Fact]
public async Task A_re_executed_query_returns_the_rows_rather_than_the_pending_acknowledgement()
{
// Arrange — execute-query acknowledges without a manifest, exactly as Databricks does.
_server.Given(Request.Create()
.WithPath($"/api/2.0/genie/spaces/{Agent}/conversations/{Conversation}/messages/{Message}/attachments/{Attachment}/execute-query")
.UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithBody(
"""{ "statement_response": { "status": { "state": "PENDING" } } }"""));

StubQueryResult(
"""{ "row_count": 1, "chunk_index": 0, "data_array": [["Germany"]] }""",
manifestExtra: """, "total_row_count": 1""");

var client = CreateClient();

// Act
var result = await client.ReExecuteQueryAsync(Agent, Conversation, Message, Attachment, Ct);

// Assert
result.ShouldNotBeNull();
result.Rows.Count.ShouldBe(1);
result.IsTruncated.ShouldBeFalse();
}

/// <summary>
/// start-conversation is not idempotent: a retry asks Genie the same question again, running
/// the SQL warehouse a second time and billing for it, and leaves an orphaned conversation
Expand Down
32 changes: 32 additions & 0 deletions tests/LakeSpeak.LiveIntegrationTests/LiveGenieTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,38 @@ public async Task A_question_returns_an_answer_and_the_sql_behind_it()
response.Text.ShouldNotBeNullOrWhiteSpace();
}

/// <summary>
/// Re-executing an attachment's query returns its rows.
/// </summary>
/// <remarks>
/// This is the documented recovery for <c>QUERY_RESULT_EXPIRED</c>, and it was covered only by
/// a contract test that stubbed a completed response — a shape Databricks does not return.
/// The real endpoint acknowledges with <c>PENDING</c> and no manifest, so the client used to
/// hand back nothing and <c>export last</c> told people to ask the question again. A stub
/// could not have caught that, because the stub was the thing that was wrong.
/// </remarks>
[Fact(SkipUnless = nameof(LiveWorkspaceConfigured), Skip = NoWorkspace)]
public async Task A_re_executed_query_returns_its_rows()
{
// Arrange — a completed question, so there is an attachment to re-run.
var agent = await ResolveAgentAsync();
var response = await Client.AskAsync(
agent.AgentId, "Total revenue by region", cancellationToken: Ct);

response.State.ShouldBe(GenieMessageState.Completed);
response.Metadata.AttachmentId.ShouldNotBeNull();

// Act — the path `export last` takes when the cached result has aged out.
var result = await Client.ReExecuteQueryAsync(
agent.AgentId, response.ConversationId!, response.MessageId!,
response.Metadata.AttachmentId!, Ct);

// Assert — rows, not the acknowledgement.
result.ShouldNotBeNull();
result.Rows.Count.ShouldBeGreaterThan(0);
result.Columns.Count.ShouldBeGreaterThan(0);
}

/// <summary>
/// The claim this project makes most loudly: a value is never reformatted on its way out.
/// Asserted against a live warehouse rather than a fixture, because a fixture cannot catch a
Expand Down
Loading