File size: 2,484 Bytes
4bbfe8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import { ModelStatRepo } from "@opencode-ai/stats-core/domain/model"
import { R2Sql } from "@opencode-ai/stats-core/r2-sql"
import { layer as statsLayer } from "@opencode-ai/stats-core/runtime"
import { syncStats } from "@opencode-ai/stats-core/stat-sync"
import { Cause, Duration, Effect, Layer, Schedule } from "effect"

const SYNC_INTERVAL = "1 hour"
const SYNC_INTERVAL_MS = 3_600_000

const runtimeLayer = Layer.mergeAll(statsLayer, R2Sql.layer)

const daemon = Effect.gen(function* () {
  yield* Effect.logInfo("stats sync daemon started")
  yield* initialDelay()

  // One full pass per UTC day (including the first pass after boot) refreshes the
  // whole display window; every other pass only recomputes the current ISO week.
  let lastFullDay = ""
  const pass = Effect.gen(function* () {
    const today = new Date().toISOString().slice(0, 10)
    if (lastFullDay !== today) {
      const completed = yield* syncStats({ full: true }).pipe(
        Effect.as(true),
        Effect.catchCause((cause) =>
          Effect.logWarning(`full stats sync failed; falling back to incremental sync ${Cause.pretty(cause)}`).pipe(
            Effect.as(false),
          ),
        ),
      )
      lastFullDay = today
      if (completed) return
    }
    yield* syncStats({ full: false })
  }).pipe(
    Effect.catchCause((cause) =>
      Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`),
    ),
  )
  yield* pass.pipe(Effect.repeat(Schedule.fixed(SYNC_INTERVAL)))
}).pipe(Effect.forkScoped)

// A restarted daemon must not immediately re-run the R2 SQL pass; resume the
// hourly cadence from the last completed sync instead. This caps the query spend
// of a crash loop at one pass per interval.
const initialDelay = Effect.fnUntraced(function* () {
  const modelStats = yield* ModelStatRepo
  const lastSynced = yield* modelStats.lastSyncedAt().pipe(Effect.catchCause(() => Effect.succeed(null)))
  if (!lastSynced) return
  const delayMs = Math.min(SYNC_INTERVAL_MS - (Date.now() - lastSynced.getTime()), SYNC_INTERVAL_MS)
  if (delayMs <= 0) return
  yield* Effect.logInfo(
    `stats sync delaying first pass ${JSON.stringify({ lastSyncedAt: lastSynced.toISOString(), delayMs })}`,
  )
  yield* Effect.sleep(Duration.millis(delayMs))
})

NodeRuntime.runMain(Layer.launch(Layer.effectDiscard(daemon).pipe(Layer.provide(runtimeLayer))), {
  disableErrorReporting: true,
})