ingest.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { Buffer } from "node:buffer"
  2. import { FirehoseClient, PutRecordBatchCommand } from "@aws-sdk/client-firehose"
  3. import { Effect, Layer, Schema } from "effect"
  4. import * as Context from "effect/Context"
  5. import { Resource } from "sst/resource"
  6. const MAX_FIREHOSE_BATCH_SIZE = 500
  7. const MAX_FIREHOSE_ATTEMPTS = 3
  8. const LAKE_TYPE = /^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)$/
  9. type IngestEvent = Record<string, unknown>
  10. type LakeRoute = { database: string; table: string }
  11. type FirehoseRecord = { Data: Uint8Array }
  12. export class IngestError extends Schema.TaggedErrorClass<IngestError>()("IngestError", {
  13. message: Schema.String,
  14. failed: Schema.Number,
  15. cause: Schema.optional(Schema.Defect()),
  16. }) {}
  17. export declare namespace Ingest {
  18. export interface Service {
  19. readonly write: (events: unknown[]) => Effect.Effect<{ records: number }, IngestError>
  20. }
  21. }
  22. export class Ingest extends Context.Service<Ingest, Ingest.Service>()("@opencode/stats/Ingest") {
  23. static readonly layer: Layer.Layer<Ingest> = Layer.effect(
  24. Ingest,
  25. Effect.sync(() => {
  26. const client = new FirehoseClient({})
  27. const write = Effect.fn("Ingest.write")(function* (events: unknown[]) {
  28. if (events.length === 0) return { records: 0 }
  29. const counts = countRoutedEvents(events)
  30. if (counts.unsupported > 0) {
  31. yield* Effect.logWarning(
  32. `lake ingest rejected ${JSON.stringify({ records: counts.records, unsupported: counts.unsupported })}`,
  33. )
  34. return yield* new IngestError({
  35. message: "Unsupported lake event type",
  36. failed: counts.unsupported,
  37. })
  38. }
  39. if (counts.records === 0) return { records: 0 }
  40. let batch: FirehoseRecord[] = []
  41. let batches = 0
  42. let failed = 0
  43. for (const event of events) {
  44. if (!isRecord(event)) continue
  45. const route = routeEvent(event)
  46. if (!route) continue
  47. batch.push(toFirehoseRecord(event, route))
  48. if (batch.length < MAX_FIREHOSE_BATCH_SIZE) continue
  49. failed += yield* putRecords(client, Resource.LakeIngestConfig.streamName, batch)
  50. batches++
  51. batch = []
  52. }
  53. if (batch.length > 0) {
  54. failed += yield* putRecords(client, Resource.LakeIngestConfig.streamName, batch)
  55. batches++
  56. }
  57. if (failed > 0) {
  58. yield* Effect.logWarning(`lake ingest incomplete ${JSON.stringify({ records: counts.records, failed })}`)
  59. return yield* new IngestError({ message: "Failed to ingest all lake records", failed })
  60. }
  61. yield* Effect.logInfo(`lake ingest complete ${JSON.stringify({ records: counts.records, batches })}`)
  62. return { records: counts.records }
  63. })
  64. return Ingest.of({ write })
  65. }),
  66. )
  67. }
  68. const putRecords: (
  69. client: FirehoseClient,
  70. streamName: string,
  71. records: FirehoseRecord[],
  72. attempt?: number,
  73. ) => Effect.Effect<number, IngestError> = Effect.fn("Ingest.putRecords")(function* (
  74. client,
  75. streamName,
  76. records,
  77. attempt = 1,
  78. ) {
  79. const result = yield* Effect.tryPromise({
  80. try: () => client.send(new PutRecordBatchCommand({ DeliveryStreamName: streamName, Records: records })),
  81. catch: (cause) =>
  82. new IngestError({ message: "Failed to write lake records to Firehose", failed: records.length, cause }),
  83. }).pipe(
  84. Effect.tapError(() =>
  85. Effect.logWarning(`firehose batch write failed ${JSON.stringify({ records: records.length, attempt })}`),
  86. ),
  87. )
  88. const failed =
  89. result.RequestResponses?.flatMap((item, index) => {
  90. const record = records[index]
  91. if (!item.ErrorCode || !record) return []
  92. return [record]
  93. }) ?? []
  94. if (failed.length === 0) return 0
  95. if (attempt >= MAX_FIREHOSE_ATTEMPTS) {
  96. yield* Effect.logWarning(
  97. `firehose batch failed ${JSON.stringify({ records: failed.length, attempts: MAX_FIREHOSE_ATTEMPTS })}`,
  98. )
  99. return failed.length
  100. }
  101. yield* Effect.logWarning(
  102. `firehose batch retrying ${JSON.stringify({ records: failed.length, attempt: attempt + 1 })}`,
  103. )
  104. yield* Effect.sleep(`${250 * 2 ** (attempt - 1)} millis`)
  105. return yield* putRecords(client, streamName, failed, attempt + 1)
  106. })
  107. function countRoutedEvents(events: unknown[]) {
  108. let records = 0
  109. let unsupported = 0
  110. for (const event of events) {
  111. if (!isRecord(event)) continue
  112. if (routeEvent(event)) records++
  113. else unsupported++
  114. }
  115. return { records, unsupported }
  116. }
  117. function isRecord(item: unknown): item is IngestEvent {
  118. return Boolean(item) && typeof item === "object" && !Array.isArray(item)
  119. }
  120. function routeEvent(event: IngestEvent): LakeRoute | undefined {
  121. if (typeof event._datalake_key !== "string") return
  122. const match = event._datalake_key.match(LAKE_TYPE)
  123. if (!match?.[1] || !match[2]) return
  124. return {
  125. database: match[1],
  126. table: match[2],
  127. }
  128. }
  129. function toFirehoseRecord(event: IngestEvent, route: LakeRoute): FirehoseRecord {
  130. return {
  131. Data: Buffer.from(
  132. JSON.stringify({
  133. ...Object.fromEntries(Object.entries(event).filter(([key]) => key !== "_datalake_key")),
  134. _lake_database: route.database,
  135. _lake_table: route.table,
  136. _lake_operation: "insert" as const,
  137. }),
  138. ),
  139. }
  140. }