athena.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. import {
  2. AthenaClient as AwsAthenaClient,
  3. GetQueryExecutionCommand,
  4. GetQueryResultsCommand,
  5. StartQueryExecutionCommand,
  6. type Row,
  7. } from "@aws-sdk/client-athena"
  8. import { Effect, Layer } from "effect"
  9. import * as Context from "effect/Context"
  10. import { Resource } from "sst/resource"
  11. const ATHENA_MAX_POLL_ATTEMPTS = 300
  12. const ATHENA_PAGE_SIZE = 1000
  13. export type AthenaData = Record<string, string>
  14. export class AthenaQueryError extends Error {
  15. readonly _tag = "AthenaQueryError"
  16. readonly queryExecutionId?: string
  17. constructor(input: { message: string; queryExecutionId?: string; cause?: unknown }) {
  18. super(input.message, { cause: input.cause })
  19. this.name = "AthenaQueryError"
  20. this.queryExecutionId = input.queryExecutionId
  21. }
  22. }
  23. export class AthenaQueryTimeoutError extends Error {
  24. readonly _tag = "AthenaQueryTimeoutError"
  25. readonly queryExecutionId: string
  26. constructor(input: { message: string; queryExecutionId: string }) {
  27. super(input.message)
  28. this.name = "AthenaQueryTimeoutError"
  29. this.queryExecutionId = input.queryExecutionId
  30. }
  31. }
  32. export declare namespace Athena {
  33. export interface Service {
  34. readonly query: (query: string) => Effect.Effect<AthenaData[], AthenaQueryError | AthenaQueryTimeoutError>
  35. }
  36. }
  37. export class Athena extends Context.Service<Athena, Athena.Service>()("@kirincode/stats/Athena") {
  38. static readonly layer: Layer.Layer<Athena> = Layer.effect(
  39. Athena,
  40. Effect.sync(() => {
  41. const client = new AwsAthenaClient({ region: Resource.InferenceEvent.region })
  42. const query = Effect.fn("Athena.query")(function* (query: string) {
  43. const started = yield* Effect.tryPromise({
  44. try: () =>
  45. client.send(
  46. new StartQueryExecutionCommand({
  47. QueryString: query,
  48. WorkGroup: Resource.InferenceEvent.workgroup,
  49. QueryExecutionContext: {
  50. Catalog: Resource.InferenceEvent.catalog,
  51. Database: Resource.InferenceEvent.database,
  52. },
  53. }),
  54. ),
  55. catch: (cause) => new AthenaQueryError({ message: "Failed to start Athena stats query", cause }),
  56. })
  57. const queryExecutionId = started.QueryExecutionId
  58. if (!queryExecutionId)
  59. return yield* Effect.fail(new AthenaQueryError({ message: "Athena did not return a query execution id" }))
  60. yield* poll(client, queryExecutionId)
  61. return yield* results(client, queryExecutionId)
  62. })
  63. return Athena.of({ query })
  64. }),
  65. )
  66. }
  67. const poll: (
  68. client: AwsAthenaClient,
  69. queryExecutionId: string,
  70. attempt?: number,
  71. ) => Effect.Effect<void, AthenaQueryError | AthenaQueryTimeoutError> = Effect.fn("Athena.poll")(function* (
  72. client: AwsAthenaClient,
  73. queryExecutionId: string,
  74. attempt = 0,
  75. ) {
  76. if (attempt > 0) yield* Effect.sleep("2 seconds")
  77. const result = yield* Effect.tryPromise({
  78. try: () => client.send(new GetQueryExecutionCommand({ QueryExecutionId: queryExecutionId })),
  79. catch: (cause) => new AthenaQueryError({ message: "Failed to poll Athena stats query", queryExecutionId, cause }),
  80. })
  81. const status = result.QueryExecution?.Status
  82. if (status?.State === "SUCCEEDED") return
  83. if (status?.State === "FAILED" || status?.State === "CANCELLED")
  84. return yield* Effect.fail(
  85. new AthenaQueryError({
  86. message: `Athena stats query ${status.State.toLowerCase()}: ${status.StateChangeReason ?? "unknown reason"}`,
  87. queryExecutionId,
  88. }),
  89. )
  90. if (attempt >= ATHENA_MAX_POLL_ATTEMPTS - 1)
  91. return yield* Effect.fail(
  92. new AthenaQueryTimeoutError({
  93. message: `Athena stats query ${queryExecutionId} did not complete`,
  94. queryExecutionId,
  95. }),
  96. )
  97. return yield* poll(client, queryExecutionId, attempt + 1)
  98. })
  99. const results: (client: AwsAthenaClient, queryExecutionId: string) => Effect.Effect<AthenaData[], AthenaQueryError> =
  100. Effect.fn("Athena.results")(function* (client: AwsAthenaClient, queryExecutionId: string) {
  101. // Accumulate pages iteratively; recursive spreads copied every previously
  102. // fetched row per page and blew up memory on large result sets.
  103. const rows: AthenaData[] = []
  104. let nextToken: string | undefined
  105. while (true) {
  106. const result = yield* Effect.tryPromise({
  107. try: () =>
  108. client.send(
  109. new GetQueryResultsCommand({
  110. QueryExecutionId: queryExecutionId,
  111. NextToken: nextToken,
  112. MaxResults: ATHENA_PAGE_SIZE,
  113. }),
  114. ),
  115. catch: (cause) =>
  116. new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }),
  117. })
  118. const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? []
  119. // The first page starts with the header row.
  120. for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row))
  121. if (!result.NextToken) return rows
  122. nextToken = result.NextToken
  123. }
  124. })
  125. function rowData(columns: string[], row: Row): AthenaData {
  126. return Object.fromEntries(
  127. columns.flatMap((column, index) => {
  128. const value = row.Data?.[index]?.VarCharValue
  129. if (!column || value === undefined) return []
  130. return [[column, value]]
  131. }),
  132. )
  133. }