// Grace hash join: when a join's inputs exceed the
// memory budget, both sides are partitioned by the hash of their join
// keys into per-partition spill runs (the same SpillStore the sort
// uses), and the existing in-memory join_step replays one partition at
// a time. Equal keys always land in the same partition, so a
// per-partition join is exactly the global join; NULL keys never match
// and may land anywhere. The engine still never touches the filesystem:
// partitions live in the injected SpillStore and are removed on every
// exit path.

///|
/// Total estimated byte size of a batch array.
fn batches_bytes(batches : Array[@types.DataBatch]) -> Int32 {
  let mut total = 0
  for b in batches {
    total += estimate_batch_bytes(b)
  }
  total
}

///|
fn mix32(h : Int32, v : Int32) -> Int32 {
  (h ^ v) * 16777619
}

///|
/// Partition id for row `r` of the key vectors: FNV-1a over the key
/// values (a NULL key component contributes a fixed marker), reduced
/// modulo `p`. Equal keys land in the same partition; distinct keys may
/// share one, which the per-partition join resolves.
fn key_partition(
  keys : Array[@types.ColumnVector],
  r : Int32,
  p : Int32,
) -> Int32 {
  // 2166136261 (the FNV offset basis) as a 32-bit two's complement
  let mut h = -2128831035
  for kv in keys {
    match kv.get(r) {
      @types.Null => h = mix32(h, -2128831035)
      @types.Boolean(v) => h = mix32(h, if v { 1 } else { 2 })
      @types.Int32(v) => h = mix32(h, v)
      @types.Int64(v) => h = mix32(h, v.to_int())
      @types.Float64(v) => h = mix32(h, v.to_int())
      @types.Str(s) =>
        for ch in s {
          h = mix32(h, ch.to_int())
        }
      @types.Date(v) => h = mix32(h, v)
    }
  }
  let m = h % p
  if m < 0 {
    m + p
  } else {
    m
  }
}

///|
/// Read one partition's spill runs back as batches (each spill file
/// deserializes to the batch sequence that was written into it).
fn read_partition(
  store : SpillStore,
  ids : Array[Int32],
) -> Array[@types.DataBatch] raise @types.SqlError {
  let out : Array[@types.DataBatch] = []
  for id in ids {
    let bs = @types.DataBatch::from_spill_bytes((store.read)(id))
    for b in bs {
      out.push(b)
    }
  }
  out
}

///|
/// Drop every spill file of both sides' partitions.
fn remove_join_partitions(
  store : SpillStore,
  wl : Array[RunWriter],
  wr : Array[RunWriter],
) -> Unit raise @types.SqlError {
  for w in wl {
    for id in w.ids {
      let _ = (store.remove)(id)
    }
  }
  for w in wr {
    for id in w.ids {
      let _ = (store.remove)(id)
    }
  }
}

///|
/// Route each row of `batch` into its key-hash partition writer.
fn partition_side(
  batch : @types.DataBatch,
  key_exprs : Array[PhysExpr],
  writers : Array[RunWriter],
) -> Unit raise @types.SqlError {
  let p = writers.length()
  let key_vecs : Array[@types.ColumnVector] = []
  for e in key_exprs {
    key_vecs.push(eval_vec(e, batch))
  }
  let idx : Array[Array[Int32]] = []
  for _ in 0..

0 { writers[k].push(take_rows(batch, idx[k])) } } } ///| /// Cap on how many hash partitions one level may create. const JOIN_PARTITION_CAP : Int32 = 256 ///| /// How many times an over-budget partition may be split again. Two /// extra levels reach 256 x 256 partitions, which covers a very small /// budget against a much larger input; what they cannot cover is one /// join-key value holding most of a side, since equal keys must land in /// the same partition at every level. const JOIN_MAX_DEPTH : Int32 = 2 ///| /// Partition count for `bytes` under `budget`: proportional to the /// input, at least two, at most JOIN_PARTITION_CAP. fn partitions_for(bytes : Int32, budget : Int32) -> Int32 { let mut p = bytes * 4 / budget + 1 if p < 2 { p = 2 } if p > JOIN_PARTITION_CAP { p = JOIN_PARTITION_CAP } p } ///| /// Grace hash join: partition both sides by join-key hash into spill /// runs, then replay `join_step` per partition and concatenate. /// /// A partition that still exceeds the budget after the first level is /// partitioned again (up to JOIN_MAX_DEPTH) instead of failing: the /// common cause is a small budget against a large input, where the /// partition cap alone leaves partitions too big. Equal keys land in /// the same partition at every level, so a multi-level split still /// computes exactly the global join. Only one kind of skew survives it /// — a single join-key value holding most of a side — and that is /// reported with a message that says so, because no hash partitioning /// can split a key. fn grace_join( acc : Array[@types.DataBatch], right : Array[@types.DataBatch], step : JoinStep, budget : Int32, store : SpillStore, stats : Array[(String, Int64)], ) -> @types.DataBatch raise @types.SqlError { let out = join_partitions(acc, right, step, budget, store, stats, 0) concat_batches(out, step.out_dtypes) } ///| /// One partitioning level of the grace join, recursing on over-budget /// partitions. `depth` counts the extra splits already applied. fn join_partitions( left : Array[@types.DataBatch], build : Array[@types.DataBatch], step : JoinStep, budget : Int32, store : SpillStore, stats : Array[(String, Int64)], depth : Int32, ) -> Array[@types.DataBatch] raise @types.SqlError { let p = partitions_for(batches_bytes(left) + batches_bytes(build), budget) let wl : Array[RunWriter] = [] let wr : Array[RunWriter] = [] for _ in 0..

0 { if depth == 0 { stats.push(("spill partitions", p.to_int64())) } else { stats.push(("spill repartitions", p.to_int64())) } } errdefer remove_join_partitions(store, wl, wr) let out : Array[@types.DataBatch] = [] for k in 0..

\{budget}) after \{depth} extra split level(s); one join-key value dominates a side, which no hash partitioning can split — raise the budget or filter that key", ) } } remove_join_partitions(store, wl, wr) out }