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
4 changes: 2 additions & 2 deletions docs/docs/configure/warehouses.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Each warehouse has a key (the connection name) and a config object:
Use `{env:...}` substitution for passwords and tokens so you never commit secrets to version control.

!!! note "Where saved passwords go"
When the agent adds a connection for you, passwords, keys and tokens are kept in your OS credential store (macOS Keychain, Windows Credential Manager, or libsecret on Linux), never in `connections.json`. If no credential store is available, the credential is not saved: the connection works for that session only, and Altimate Code says so. Use an `ALTIMATE_CODE_CONN_<NAME>` environment variable instead in that case. Removing a connection removes its credentials from the store, and saving a connection again under the same name drops credentials it no longer uses.
When the agent adds a connection for you, passwords, keys and tokens are kept in your OS credential store (macOS Keychain, Windows Credential Manager, or libsecret on Linux), never in `connections.json`. If no credential store is available, the credential is not saved: the connection works for that session only, and Altimate Code says so. In that case set an `ALTIMATE_CODE_CONN_<NAME>` environment variable to the connection's full config as JSON (not just the password); for a name containing `-`, set it with `env`, since a shell assignment cannot use that character. Removing a saved connection removes its credentials from the store, unless this project's connections file or an environment variable still defines a connection by that name. Saving a connection again under the same name drops the credentials it no longer uses once the new config is written. Credentials are stored by connection name, so a project file that defines a connection of the same name without its own password was using the saved connection's, and loses it when that one is removed. If the store refuses a removal, Altimate Code says which credentials remain.

## Snowflake

Expand Down Expand Up @@ -53,7 +53,7 @@ Each warehouse has a key (the connection name) and a config object:
| `database` | No | Database name |
| `schema` | No | Schema name |
| `role` | No | User role |
| `client_session_keep_alive` | No | Keep the session alive while Altimate Code is running (default `true`). A connection Snowflake closes anyway is reopened automatically. A read-only query interrupted by the close is run again; a statement that changes data is not, because it may already have run, and the error says so. After a reopen, `USE` and `ALTER SESSION` settings must be run again. |
| `client_session_keep_alive` | No | Keep the session alive while Altimate Code is running (default `true`). A connection Snowflake closes anyway is reopened automatically. `USE`, `ALTER SESSION` and `SET` settings are applied again to the reopened session. A single read-only query interrupted by the close is run again; a statement that changes data, or calls a sequence, is not, because it may already have run, and the error says so. Temporary tables and an open transaction cannot be restored: statements issued before the reopen report that instead of running. |

### Key-pair authentication

Expand Down
6 changes: 3 additions & 3 deletions docs/docs/reference/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,11 @@ In debug mode the log records every tool call as it starts and ends, and while o
altimate debug bundle
```

This writes `altimate-debug-report-<time>.md` in the current folder (use `--output <path>` to choose another place). It lists the problems it detected first, then the evidence: version and install method, operating system and terminal, warehouse connections (names, types, sign-in method and which fields are set, never values), MCP servers, a summary of the log, and the last 300 log lines. It also checks that the Snowflake and Databricks warehouses, the Altimate API, the telemetry endpoint (when telemetry is on) and the model catalogue this installation uses are reachable over HTTPS. Add `--no-network` and the command makes no network requests at all, telemetry included. It does not start your project (plugins, language servers), so it still works when that is what hangs. The file is readable only by you.
This writes `altimate-debug-report-<time>.md` in the current folder (use `--output <path>` to choose another place). It lists the problems it detected first, then the evidence: version and install method, operating system and terminal, warehouse connections (names, types, sign-in method and which fields are set, never values), MCP servers, a summary of the log, and the last 300 log lines. It also checks that the Snowflake and Databricks warehouses, the Altimate API, the telemetry endpoint (when telemetry is on) and the model catalogue this installation uses are reachable over HTTPS. Add `--no-network` (or `--network=false`) and the command makes no network requests at all, telemetry included. It does not start your project (plugins, language servers), so it still works when that is what hangs. The file is created readable only by you, where the filesystem supports it.

Passwords, keys, tokens and other secret settings (in any `name=value`, JSON or connection-URL form), email addresses, URL parameters, your home folder, your user name and your computer's name are removed. The report stays on your machine: read it, then send it to Altimate support yourself.
Passwords, keys, tokens and other secret settings (in `name=value`, JSON and connection-URL forms), email addresses, URL parameters, your home folder, your user name and your computer's name are removed. This is pattern-based and can miss a secret in an unusual format, so read the report before you send it. It stays on your machine until you send it to Altimate support yourself.

For raw logs printed to the terminal instead:
To save the raw logs to a file instead:

```bash
altimate --print-logs --log-level DEBUG 2>debug.log
Expand Down
190 changes: 169 additions & 21 deletions packages/drivers/src/snowflake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ function stripLeadingComments(sql: string): string {
}
}

/** Keywords that change data, objects or the session, anywhere in a statement. */
/** Keywords that change data, objects or the session, anywhere in a statement. Sequence generators and SYSTEM$
* functions are included: `SELECT seq.NEXTVAL` advances a sequence, and several SYSTEM$ functions act. */
const WRITE_KEYWORDS =
/\b(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|COPY|CALL|PUT|GET|REMOVE|GRANT|REVOKE|UNDROP|EXECUTE|BEGIN|COMMIT|ROLLBACK|SET|UNSET|USE)\b/i
/\b(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|COPY|CALL|PUT|GET|REMOVE|GRANT|REVOKE|UNDROP|EXECUTE|BEGIN|COMMIT|ROLLBACK|SET|UNSET|USE|NEXTVAL|GETNEXTVAL)\b|SYSTEM\$/i

/**
* True when sending `sql` a second time cannot change anything: a single
Expand All @@ -98,14 +99,59 @@ export function isRetrySafe(sql: string): boolean {
return false
}

/** Statements whose effect lives in the session (current database/schema/role, session parameters, variables,
* temporary objects, an open transaction) and is lost when the session is replaced. */
export function changesSession(sql: string): boolean {
/** The statement with string literals emptied, so a `;` or keyword inside a literal is not mistaken for SQL. */
function withoutLiterals(sql: string): string {
return stripLeadingComments(sql).replace(/'(?:[^']|'')*'/g, "''").replace(/;\s*$/, "")
}

/** Starts like a session-scoped change: current database/schema/role/warehouse, a parameter or a variable. */
function looksLikeSessionChange(sql: string): boolean {
return /^(USE|SET|UNSET|ALTER\s+SESSION)\b/i.test(stripLeadingComments(sql))
}

/**
* A session setting that can be applied again to a new session with the same effect: one statement, with no
* subquery, sequence or SYSTEM$ call in it. `SET v = (SELECT seq.NEXTVAL)` is a setting, but replaying it would
* consume another value, so it is not one of these.
*/
export function isSessionSetting(sql: string): boolean {
if (!looksLikeSessionChange(sql)) return false
const s = withoutLiterals(sql)
return !s.includes(";") && !/\(\s*SELECT\b|\bNEXTVAL\b|\bGETNEXTVAL\b|SYSTEM\$/i.test(s)
}

/** Opens a transaction, which a new session does not have. */
function opensTransaction(sql: string): boolean {
return /^(BEGIN|START\s+TRANSACTION)\b/i.test(stripLeadingComments(sql))
}

/** Ends the open transaction. */
function endsTransaction(sql: string): boolean {
return /^(COMMIT|ROLLBACK)\b/i.test(stripLeadingComments(sql))
}

/**
* Session state that cannot be re-created by replaying a statement: temporary objects, an open transaction, and
* session changes that are not safe to replay (several statements in one, or a value computed with side effects).
*/
export function holdsSessionState(sql: string): boolean {
const s = stripLeadingComments(sql)
return /^(USE|SET|UNSET|BEGIN|START\s+TRANSACTION|ALTER\s+SESSION)\b/i.test(s) ||
/^CREATE\s+(OR\s+REPLACE\s+)?(LOCAL\s+|GLOBAL\s+)?(TEMP|TEMPORARY|VOLATILE)\b/i.test(s)
return opensTransaction(sql) ||
/^CREATE\s+(OR\s+REPLACE\s+)?(LOCAL\s+|GLOBAL\s+)?(TEMP|TEMPORARY|VOLATILE)\b/i.test(s) ||
(looksLikeSessionChange(sql) && !isSessionSetting(sql))
}

/** Either kind: a statement whose effect lives only in the session. */
export function changesSession(sql: string): boolean {
return isSessionSetting(sql) || holdsSessionState(sql)
}

/** Cap on remembered session settings; past it the session is treated as not replayable. */
const MAX_SESSION_SETTINGS = 100

/** Replay passes before settings that keep arriving during a reconnect are given up on. */
const MAX_REPLAY_PASSES = 5

/** `client_session_keep_alive` / `clientSessionKeepAlive`; on unless explicitly false. */
export function keepAliveSetting(config: ConnectionConfig): boolean {
const value = config.client_session_keep_alive ?? config.clientSessionKeepAlive
Expand Down Expand Up @@ -154,8 +200,20 @@ export async function connect(
let reconnecting: Promise<void> | undefined
/** Set by close(): a reconnect that finishes afterwards must not install a session nobody will close. */
let closed = false
/** A statement changed session state, which a reconnect silently drops; such a session's statements are not resent. */
let sessionChanged = false
/** Session settings that succeeded, in order, replayed onto every reopened connection before it is used. */
const sessionSettings: string[] = []
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
/** Counts installed connections. A statement remembers the generation it started in. */
let generation = 0
/** Counts explicit `connect()` calls. A result from before the latest one belongs to a session the caller chose to
* replace, and is never carried onto the fresh one. */
let epoch = 0
/** The generation whose session holds state that cannot be replayed: temporary objects or unreplayable settings
* (`tempIn`), and an open transaction (`txnIn`, cleared by COMMIT/ROLLBACK). */
let tempIn: number | undefined
let txnIn: number | undefined
/** The generation at which that state was lost: statements that started before it are not run, because they
* may depend on it; statements issued after it run on the new session. Never cleared by a late error. */
let stateLostAt: number | undefined

function openConnection(): Promise<any> {
const account = connectOptions?.account
Expand Down Expand Up @@ -236,15 +294,51 @@ export async function connect(
if (!reconnecting) {
const previous = connection
reconnecting = openConnection()
.then((conn) => {
if (closed) {
.then(async (conn) => {
const discard = () => {
try {
conn.destroy?.(() => {})
} catch {
// nothing to release
}
}
// The session's settings are restored before anyone can use the connection, so every caller waiting on
// this reconnect runs with them. Each pass replays a snapshot (a late setting may reorder the live list
// mid-pass); a setting that completed on the old connection during a pass changes the list, so the list is
// replayed again until a pass ends with it unchanged. Settings are safe to apply twice. Nothing awaits
// between the last check and installing the connection below, so none can slip in after it.
for (let pass = 0; ; pass++) {
const snapshot = [...sessionSettings]
for (const setting of snapshot) {
try {
await runQuery(conn, setting)
} catch (err) {
discard()
throw new Error(
`Snowflake closed the session and its settings could not be restored on a new one (${setting}): ${(err as Error)?.message ?? err}`,
)
}
}
const unchanged = snapshot.length === sessionSettings.length && snapshot.every((v, i) => v === sessionSettings[i])
if (unchanged) break
if (pass + 1 >= MAX_REPLAY_PASSES) {
// Settings keep arriving: a connection installed now would run later queries with some of them missing.
discard()
throw new Error(
"Snowflake closed the session and it could not be restored on a new one: session settings kept changing while they were being restored. Try again.",
)
}
}
if (closed) {
discard()
throw new Error("Snowflake connection was closed while reconnecting")
}
generation++
if (tempIn !== undefined || txnIn !== undefined) {
stateLostAt = generation
tempIn = undefined
txnIn = undefined
}
connection = conn
suppressSnowflakeLogging(snowflake)
try {
Expand All @@ -260,25 +354,63 @@ export async function connect(
return reconnecting
}

/** The error for a statement held back because the replaced session had state it depended on. Reported once:
* the reopened session starts clean, so later statements run as usual. */
/** For a statement that started before the session holding temporary objects or a transaction was replaced. */
function sessionLostError(cause: string): Error {
sessionChanged = false
return new Error(
`Snowflake closed the session (${cause}), and its USE, ALTER SESSION, variables or temporary objects went with it, ` +
`so this statement was not run on the new session. The connection has been reopened; run those statements again first.`,
`Snowflake closed the session (${cause}), and its temporary objects or open transaction went with it, ` +
`so this statement was not run on the new session. The connection has been reopened (session settings were restored); ` +
`re-create what the statement depends on, then run it again.`,
)
}

/** Whether a statement that started in generation `started` may depend on state that has since been lost. */
function lostFor(started: number): boolean {
return stateLostAt !== undefined && started < stateLostAt
}

/** Reopen before use when the SDK already knows the connection is gone (idle timeout, network drop, sleep). */
async function ensureLive(): Promise<void> {
if (reconnecting) return reconnecting
if (connection && connectOptions && typeof connection.isUp === "function" && !connection.isUp()) {
await reconnect()
if (sessionChanged) throw sessionLostError("it had expired")
}
}

/**
* Remember what a successful statement did to the session. `ranIn` is the generation it ran in: when another
* statement has replaced the connection meanwhile, its effect is on a session that is gone. A late setting is
* applied to the current session too; late state that cannot be replayed is reported to its caller.
*/
async function noteSession(sql: string, binds: any[] | undefined, ranIn: number, ranInEpoch: number): Promise<void> {
const replayable = isSessionSetting(sql) && !(binds && binds.length)
// From before an explicit connect(): that session was replaced on purpose; nothing of it carries over.
if (ranInEpoch !== epoch) return
if (ranIn !== generation) {
if (replayable) {
// Not remembered (the list is full): the setting cannot be restored by a later reconnect.
if (!rememberSetting(sql)) tempIn = generation
await runQuery(connection, sql)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} else if (holdsSessionState(sql) || (looksLikeSessionChange(sql) && !replayable)) {
throw sessionLostError("it was replaced while this statement ran")
}
return
}
if (endsTransaction(sql)) txnIn = undefined
else if (opensTransaction(sql)) txnIn = ranIn
else if (replayable) {
if (!rememberSetting(sql)) tempIn = ranIn
} else if (holdsSessionState(sql) || looksLikeSessionChange(sql)) tempIn = ranIn
}

/** Adds a setting to the replay list, once (a repeat moves it to the end); false when the list is full. */
function rememberSetting(sql: string): boolean {
const at = sessionSettings.indexOf(sql)
if (at >= 0) sessionSettings.splice(at, 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Do not mutate the settings array during replay

reconnect() iterates sessionSettings with for...of and awaits each query. If an in-flight old-session USE SCHEMA a completes while the first replay is awaiting, this splice moves a from the first position to the end. The iterator advances to the next index, skips the setting previously there (for example ALTER SESSION SET TIMEZONE), and installs a replacement with incomplete session state. Replay a stable snapshot or serialize changes to the replay list.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0d45e57: reconnect replays a snapshot ([...sessionSettings]). Test: a repeat of the first setting completes during the replay, and the second setting is still applied.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
else if (sessionSettings.length >= MAX_SESSION_SETTINGS) return false
sessionSettings.push(sql)
return true
}

function escapeSqlIdentifier(value: string): string {
return value.replace(/"/g, '""')
}
Expand All @@ -289,11 +421,16 @@ export async function connect(
* it may already have run (see `isRetrySafe`).
*/
async function executeQuery(sql: string, binds?: any[]): Promise<{ columns: string[]; rows: any[][] }> {
// Captured before any wait: a statement queued behind a reconnect started on the old session's assumptions.
const started = generation
const startedInEpoch = epoch
await ensureLive()
if (lostFor(started)) throw sessionLostError("it had expired")
const used = connection
const ranIn = generation
try {
const result = await runQuery(used, sql, binds)
if (changesSession(sql)) sessionChanged = true
await noteSession(sql, binds, ranIn, startedInEpoch)
return result
} catch (err) {
if (!connectOptions || !isClosedConnectionError(err)) throw err
Expand All @@ -302,14 +439,18 @@ export async function connect(
if (connection === used) await reconnect()
else if (reconnecting) await reconnecting
const cause = String((err as Error)?.message ?? err)
if (sessionChanged) throw sessionLostError(cause)
if (!isRetrySafe(sql)) {
if (lostFor(started)) throw sessionLostError(cause)
// A session setting is safe to apply twice, and must be applied for the statements that follow it.
if (!isRetrySafe(sql) && !isSessionSetting(sql)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Do not resend effectful session settings after an ambiguous close

A 407002 can be returned while polling a statement Snowflake already executed. SET v = (SELECT seq.NEXTVAL) (or an ALTER SESSION expression with side effects) is classified as a setting here and resent on the new session, consuming the sequence again. Replay only demonstrably idempotent settings, or report that the outcome is unknown instead of automatically retrying arbitrary SET statements.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 183aa6b: a setting is replayable only if it is one statement with no subquery, sequence or SYSTEM$ call (literals ignored). SET v = (SELECT seq.NEXTVAL) is not replayed or resent after a close. The session it ran on is treated as holding state that cannot be restored, and later statements that depend on it are reported.

throw new Error(
`Snowflake closed the session while this statement was running, so it was not run again: it may change data ` +
`and could already have run. The connection has been reopened; check the result before running it again. (${cause})`,
)
}
return runQuery(connection, sql, binds)
const ranAgainIn = generation
const result = await runQuery(connection, sql, binds)
await noteSession(sql, binds, ranAgainIn, startedInEpoch)
return result
}
}

Expand Down Expand Up @@ -338,6 +479,13 @@ export async function connect(
return {
async connect() {
closed = false
// A fresh session: nothing recorded for an earlier one applies to it.
sessionSettings.length = 0
tempIn = undefined
txnIn = undefined
stateLostAt = undefined
generation++
epoch++
const options: Record<string, unknown> = {
account: config.account,
username: config.user ?? config.username,
Expand Down
Loading
Loading