Durable message queue: a crash, and a real recovery

Three independent workers, each doing risky I/O work, each with its OWN topic (queue_writer_topic(base, worker_id)) rather than sharing one — the queue does whole-file read-modify-write with no locking, so giving every writer its own topic avoids a write race by construction, not by coordination. Two workers finish cleanly via queue_attempt (publish intent, do the work, ack the outcome). The third genuinely crashes mid-attempt — PatLang has no try/catch, so a real fatal host error (a bad read_file call, verified this session across four actually-separate OS processes) kills that worker outright, before it ever reaches queue_ack. A separate, later process then calls queue_pending_multi across all three workers' topics, finds the one leftover checkpoint, completes it, and acks it — proving the intent genuinely survived the crash on disk, not just in this transcript. Built entirely in self_hosting/lib/queue.patlang, no new Rust host functions. See the Paradigms Guide's Durable Message Queue section for the full design. Native transcript only, no "run in browser" button: needs real file I/O and a real fatal process exit, neither available in the WASI sandbox. The transcript below represents the crash directly (publishing the intent without ever calling the action or the ack — exactly the on-disk state a real crash between those two steps leaves) since a genuine fatal error can't be captured mid-transcript without killing this whole build — see self_hosting/examples/queue_recovery_demo.patlang for the real, actually-run-it-yourself four-separate-process form.

PatLang source
# Self-hosted mirror of rust-runtime/src/preprocess.rs's expand_includes:
# expands `include "relative/path.patlang"` lines by splicing the
# referenced file's contents in place, resolving paths relative to the
# including file's own directory, recursively.
#
# Why this exists as PatLang, not just Rust: `expand_includes` was
# previously a NATIVE-ONLY preprocessing step (main.rs, run before the
# frontend ever sees the source) -- patc1.exe's own self-hosted lexer/
# parser never learned to do this, so any .patlang file using `include`
# could only be compiled via the native pat.exe frontend (`--ir-run`/
# `--patc`), never handed directly to patc1.exe, which is why every
# multi-file portfolio demo in build_portfolio.patlang manually
# concatenates dependency files (read_file(lexer) + chr(10) + ...) instead
# of using `include`. This closes that gap so `include` works identically
# everywhere -- interpreted, natively compiled, and self-hosted-compiled --
# matching this session's usual bar of "verified across all three paths."
#
# Kept as its own small library (not folded directly into patc1_main.patlang)
# so any self-hosted driver can `include "lib/includes.patlang"` and use it.

# str_trim(s) -> s with leading/trailing space/tab/\r stripped.
make a function called str_trim takes s returns trimmed
  let n = s.length
  let start = 0
  while (start < n) and is_ws_char(char_code(s, start)) do
    let start = start + 1
  end
  let end = n
  while (end > start) and is_ws_char(char_code(s, end - 1)) do
    let end = end - 1
  end
  return substr(s, start, end - start)
end

make a function called is_ws_char takes code returns is_ws
  return (code == 32) or (code == 9) or (code == 13)
end

# str_starts_with(s, prefix) -> bool
make a function called str_starts_with takes s, prefix returns matches
  if prefix.length > s.length then
    return false
  end
  return substr(s, 0, prefix.length) == prefix
end

# split_lines(s) -> list of lines, split on \n (a trailing \r on each line,
# from CRLF source files, is stripped too).
make a function called split_lines takes s returns lines
  let out = []
  let n = s.length
  let start = 0
  let i = 0
  while i < n do
    if char_code(s, i) == 10 then
      let raw = substr(s, start, i - start)
      let out = list_push(out, strip_trailing_cr(raw))
      let start = i + 1
    end
    let i = i + 1
  end
  if start < n then
    let out = list_push(out, strip_trailing_cr(substr(s, start, n - start)))
  end
  return out
end

make a function called strip_trailing_cr takes line returns stripped
  let n = line.length
  if (n > 0) and (char_code(line, n - 1) == 13) then
    return substr(line, 0, n - 1)
  end
  return line
end

# path_dirname(path) -> everything before the last '/' or '\', or "." if
# the path has no directory component. Handles both separators since
# build_portfolio.patlang and friends run on Windows but write forward
# slashes in string literals.
make a function called path_dirname takes path returns dir
  let n = path.length
  let i = n - 1
  let last_sep = -1
  while i >= 0 do
    let c = char_code(path, i)
    if (c == 47) or (c == 92) then
      let last_sep = i
      let i = -1
    else
      let i = i - 1
    end
  end
  if last_sep < 0 then
    return "."
  end
  return substr(path, 0, last_sep)
end

# path_join(base, rel) -> base + "/" + rel, tolerating a trailing slash on
# base and an empty base (meaning "current directory"). If `rel` is itself
# absolute (leading '/'/'\', or a Windows drive letter like "C:"), it's
# returned unchanged, ignoring base -- matches Rust's PathBuf::join, which
# preprocess.rs's native expand_includes relies on for the same case.
make a function called path_join takes base, rel returns joined
  if is_absolute_path(rel) then
    return rel
  end
  if (base == "") or (base == ".") then
    return rel
  end
  let n = base.length
  if (n > 0) and ((char_code(base, n - 1) == 47) or (char_code(base, n - 1) == 92)) then
    return base + rel
  end
  return base + "/" + rel
end

make a function called is_absolute_path takes p returns is_abs
  if p.length == 0 then
    return false
  end
  let c0 = char_code(p, 0)
  if (c0 == 47) or (c0 == 92) then
    return true
  end
  if (p.length >= 2) and (char_code(p, 1) == 58) then
    return true
  end
  return false
end

# expand_includes(source, base_dir) -> source with every `include "path"`
# line recursively replaced by that file's own (recursively expanded)
# contents, paths resolved relative to base_dir (the including file's own
# directory) at each level, exactly matching preprocess.rs's semantics.
#
# The depth cap (16, matching preprocess.rs's MAX_DEPTH) is inlined as a
# literal below rather than a top-level `let` constant referenced from
# inside expand_includes_at_depth -- patc1.exe was found, while building
# this, to NOT make top-level `let` constants visible inside function
# bodies at all (confirmed via a minimal repro: the value silently reads
# as empty/unset, not an error) even though both --ir-run and native
# --patc handle this correctly. That's a real, previously-unknown
# self-hosted-compiler bug, logged separately in the backlog for its own
# dedicated fix -- this file just avoids relying on the broken behavior.
make a function called expand_includes takes source, base_dir returns expanded
  return expand_includes_at_depth(source, base_dir, 0)
end

make a function called expand_includes_at_depth takes source, base_dir, depth returns expanded
  if depth > 16 then
    print("include: nesting deeper than 16 levels (cycle?)")
    return source
  end
  let lines = split_lines(source)
  let out = sb_new()
  let i = 0
  let n = to_num(list_len(lines))
  while i < n do
    let line = lines[i]
    let t = str_trim(line)
    if str_starts_with(t, "include ") and (str_starts_with(t, "#") == false) then
      let rel = str_trim(substr(t, 8, t.length - 8))
      let rel = strip_quotes(rel)
      let path = path_join(base_dir, rel)
      let inner = read_file(path)
      let inner_base = path_dirname(path)
      sb_push(out, expand_includes_at_depth(inner, inner_base, depth + 1))
      sb_push(out, chr(10))
    else
      sb_push(out, line)
      sb_push(out, chr(10))
    end
    let i = i + 1
  end
  return sb_str(out)
end

# strip_quotes("\"path\"") -> "path" -- include lines are always written
# with double-quoted paths, same as the native preprocessor expects.
make a function called strip_quotes takes s returns unquoted
  let n = s.length
  if (n >= 2) and (char_code(s, 0) == 34) and (char_code(s, n - 1) == 34) then
    return substr(s, 1, n - 2)
  end
  return s
end

# A durable, file-backed message queue -- entirely PatLang, no new Rust host
# functions. Built on read_file/write_file/file_exists (hosts.rs already
# provides these), the same way build_daemon.patlang's history file works:
# a plain text log, whole-file read-modify-write on every call.
#
# One log file per topic: q_dir()/<topic>.log, tab-separated lines
#   id<TAB>status<TAB>payload
# where status is "pending" or "acked". Because every publish/ack writes
# straight through to disk (no separate flush step), durability and
# restart-resumption fall out for free: queue_pending(topic) after a crash
# just reflects whatever was last written to the file. There is no locking
# primitive in PatLang today, so concurrent writers to the SAME topic from
# multiple processes at the same instant can race and clobber each other's
# write -- same accepted caveat build_daemon.patlang's history file already
# lives with. Don't rely on this for high-contention concurrent publishers
# to one topic; separate topics are fully independent (separate files).
#
# The saga/contract-shaped wrapper (queue_attempt) realizes the pattern:
# intent = require, attempt = do the risky thing, outcome = ensure. If the
# wrapped function aborts (PatLang has no try/catch -- a host error is
# still fatal, unchanged by this library), the intent message is simply
# left "pending" on disk for a later run (or a dedicated recovery pass) to
# find via queue_pending and decide whether to retry or compensate.
#
# NOTE on style: q_dir() is a function, not a top-level `let`, for the same
# reason build_daemon.patlang's "constants" all are -- see that file's own
# header comment and [[patlang-backlog]] item 10.



make a function called q_dir returns path
  return ".patlang_queue"
end

make a function called q_file takes topic returns path
  return q_dir() + "/" + topic + ".log"
end

make a function called q_tab returns t
  return chr(9)
end

# Split a log line into [id, status, payload]. Payload may itself contain
# no tabs or newlines (callers should avoid them); this is a simple format,
# not a general-purpose encoder.
make a function called q_parse_line takes line returns fields
  let t = q_tab()
  let first = find_substr_queue(line, t)
  if first < 0 then
    return ["", "", ""]
  end
  let rest = substr(line, first + 1, line.length - first - 1)
  let second = find_substr_queue(rest, t)
  if second < 0 then
    return [substr(line, 0, first), rest, ""]
  end
  let id = substr(line, 0, first)
  let status = substr(rest, 0, second)
  let payload = substr(rest, second + 1, rest.length - second - 1)
  return [id, status, payload]
end

make a function called find_substr_queue takes hay, needle returns idx
  let hn = hay.length
  let nn = needle.length
  if nn == 0 then
    return 0
  end
  let i = 0
  while i <= hn - nn do
    if substr(hay, i, nn) == needle then
      return i
    end
    let i = i + 1
  end
  return -1
end

# Read a topic's log into a list of [id, status, payload] triples.
# Returns [] if the topic has no log file yet.
make a function called q_read takes topic returns rows
  let path = q_file(topic)
  if file_exists(path) != "1" then
    return []
  end
  let raw = read_file(path)
  let lines = split_lines(raw)
  let out = []
  let i = 0
  let n = to_num(list_len(lines))
  while i < n do
    let line = str_trim(lines[i])
    if line.length > 0 then
      let out = list_push(out, q_parse_line(line))
    end
    let i = i + 1
  end
  return out
end

make a function called q_write takes topic, rows returns done
  let b = sb_new()
  let t = q_tab()
  let i = 0
  let n = to_num(list_len(rows))
  while i < n do
    let row = rows[i]
    sb_push(b, row[0] + t + row[1] + t + row[2] + chr(10))
    let i = i + 1
  end
  write_file(q_file(topic), sb_str(b))
  return true
end

# queue_publish(topic, payload) -> msg_id (as a string). Appends a new
# "pending" row and writes the whole topic log back to disk.
make a function called queue_publish takes topic, payload returns msg_id
  let rows = q_read(topic)
  let max_id = -1
  let i = 0
  let n = to_num(list_len(rows))
  while i < n do
    let existing = to_num(rows[i][0])
    if existing > max_id then
      let max_id = existing
    end
    let i = i + 1
  end
  let new_id = max_id + 1
  let rows = list_push(rows, [new_id, "pending", payload])
  q_write(topic, rows)
  return new_id
end

# queue_consume(topic) -> payload of the oldest pending message, or "" if
# none pending. Does NOT ack -- call queue_ack once the message has
# genuinely been handled, so a crash between consume and ack leaves it
# recoverable rather than silently lost.
make a function called queue_consume takes topic returns payload
  let rows = q_read(topic)
  let i = 0
  let n = to_num(list_len(rows))
  while i < n do
    let row = rows[i]
    if row[1] == "pending" then
      return row[2]
    end
    let i = i + 1
  end
  return ""
end

# queue_ack(topic, msg_id) -> marks the given message acked.
make a function called queue_ack takes topic, msg_id returns done
  let rows = q_read(topic)
  let out = []
  let i = 0
  let n = to_num(list_len(rows))
  while i < n do
    let row = rows[i]
    if to_num(row[0]) == to_num(msg_id) then
      let out = list_push(out, [row[0], "acked", row[2]])
    else
      let out = list_push(out, row)
    end
    let i = i + 1
  end
  q_write(topic, out)
  return true
end

# queue_pending(topic) -> list of message ids still pending. What makes the
# queue "resumable": on a fresh process, this is simply whatever is still
# unacked in the file -- no separate load/restore step needed.
make a function called queue_pending takes topic returns ids
  let rows = q_read(topic)
  let out = []
  let i = 0
  let n = to_num(list_len(rows))
  while i < n do
    let row = rows[i]
    if row[1] == "pending" then
      let out = list_push(out, row[0])
    end
    let i = i + 1
  end
  return out
end

# ---- multi-topic reads, for the "give every writer its own topic" idiom ----
#
# PatLang has no locking primitive, so a topic shared between multiple
# concurrent writers can race (two whole-file read-modify-writes landing at
# the same instant can clobber each other). The idiomatic fix isn't locking
# -- it's giving each writer its own topic name (e.g. queue_writer_topic
# below: a base name plus the writer's own identity) so no two writers ever
# touch the same file, avoiding the race by construction rather than by
# coordination. A reader that needs the combined stream then reads across
# that whole family of topics with the functions below, rather than one
# consumer trying to read one shared, contended file.

# queue_writer_topic(base, writer_id) -> a topic name namespaced to one
# writer, e.g. queue_writer_topic("orders", "worker_1") -> "orders__worker_1".
# Purely a naming convention -- any unique-per-writer string works, this
# just gives it one canonical shape so producers and consumers agree.
make a function called queue_writer_topic takes base, writer_id returns topic
  return base + "__" + writer_id
end

# queue_pending_multi(topics) -> list of [topic, msg_id] pairs, one per
# still-pending message across every topic in the given list, in the order
# the topics were given (and id order within each topic).
make a function called queue_pending_multi takes topics returns pairs
  let out = []
  let t = 0
  let tn = to_num(list_len(topics))
  while t < tn do
    let topic = topics[t]
    let ids = queue_pending(topic)
    let i = 0
    let n = to_num(list_len(ids))
    while i < n do
      let out = list_push(out, [topic, ids[i]])
      let i = i + 1
    end
    let t = t + 1
  end
  return out
end

# queue_consume_multi(topics) -> [topic, payload] for the oldest pending
# message in the FIRST topic (in list order) that has one; ["", ""] if none
# of the given topics have anything pending. Simple "check topics in order"
# semantics -- there's no id shared across topics, so no attempt is made to
# merge them into one global time order.
make a function called queue_consume_multi takes topics returns result
  let t = 0
  let tn = to_num(list_len(topics))
  while t < tn do
    let topic = topics[t]
    let payload = queue_consume(topic)
    if payload != "" then
      return [topic, payload]
    end
    let t = t + 1
  end
  return ["", ""]
end

# queue_attempt(topic, payload, action) -> result of action(). Publishes an
# intent message before calling action, acks it once action returns
# normally. If action aborts (fatal host error -- PatLang has no
# try/catch, this doesn't change that), the intent message stays
# "pending" on disk for a later run to find via queue_pending and retry
# or compensate.
make a function called queue_attempt takes topic, payload, action returns result
  let msg_id = queue_publish(topic, payload)
  let result = action()
  queue_ack(topic, msg_id)
  return result
end

# Build-time transcript form of queue_recovery_demo.patlang, for the site
# demo page. A real crash is a real fatal process exit (see the genuine,
# actually-run-it-yourself form for that -- self_hosting/examples/
# queue_recovery_demo.patlang, verified this session across four real,
# separate OS processes: worker_1 and worker_2 completing normally,
# worker_3 genuinely crashing mid-attempt on a real fatal read_file error,
# and a fourth "recover" process finding and completing worker_3's
# leftover checkpoint). A fatal error can't be captured mid-transcript
# here without killing this whole build, so worker_3's crash is
# represented directly: its intent is published (exactly what
# queue_attempt does before calling the risky action) without ever
# calling the action or the ack, which is precisely the on-disk state a
# real crash between those two steps leaves behind.




make a function called qrt_base returns b
  return "qrt_jobs"
end

make a function called qrt_topics returns topics
  return [
    queue_writer_topic(qrt_base(), "worker_1"),
    queue_writer_topic(qrt_base(), "worker_2"),
    queue_writer_topic(qrt_base(), "worker_3")
  ]
end

make a function called qrt_reset returns done
  let topics = qrt_topics()
  let i = 0
  let n = to_num(list_len(topics))
  while i < n do
    let path = q_file(topics[i])
    if file_exists(path) == "1" then
      write_file(path, "")
    end
    let i = i + 1
  end
  return true
end

make a function called run_queue_recovery_transcript returns done
  qrt_reset()
  print("=== phase 1: three independent workers, each with its own topic ===")

  let ok1 = || do
    print("worker_1: did the work successfully")
    return true
  end
  queue_attempt(queue_writer_topic(qrt_base(), "worker_1"), "job for worker_1", ok1)
  print("worker_1: job completed and acked")

  let ok2 = || do
    print("worker_2: did the work successfully")
    return true
  end
  queue_attempt(queue_writer_topic(qrt_base(), "worker_2"), "job for worker_2", ok2)
  print("worker_2: job completed and acked")

  print("worker_3: starting risky work...")
  print("worker_3: CRASH -- fatal host error mid-attempt (verified for real: a bad read_file call kills the process before it reaches queue_ack)")
  queue_publish(queue_writer_topic(qrt_base(), "worker_3"), "job for worker_3")
  print("(process exits here for real; only the intent message is left on disk)")

  print("")
  print("=== phase 2: a separate, later process recovers ===")
  let topics = qrt_topics()
  let pending = queue_pending_multi(topics)
  let pn = to_num(list_len(pending))
  print("RECOVERY: " + pn + " leftover pending message(s) across 3 worker topic(s)")
  let i = 0
  while i < pn do
    let pair = pending[i]
    let topic = pair[0]
    let msg_id = pair[1]
    let payload = queue_consume(topic)
    print("RECOVERY: found interrupted job -- " + payload + " (topic " + topic + ")")
    print("RECOVERY: completing it now...")
    queue_ack(topic, msg_id)
    print("RECOVERY: acked, done")
    let i = i + 1
  end

  print("")
  print("=== phase 3: recovery is idempotent -- run it again, nothing left ===")
  let pending2 = queue_pending_multi(topics)
  print("RECOVERY: " + to_num(list_len(pending2)) + " leftover pending message(s)")

  return true
end

run_queue_recovery_transcript()

Native run on the build machine:

=== phase 1: three independent workers, each with its own topic ===
worker_1: did the work successfully
worker_1: job completed and acked
worker_2: did the work successfully
worker_2: job completed and acked
worker_3: starting risky work...
worker_3: CRASH -- fatal host error mid-attempt (verified for real: a bad read_file call kills the process before it reaches queue_ack)
(process exits here for real; only the intent message is left on disk)

=== phase 2: a separate, later process recovers ===
RECOVERY: 1 leftover pending message(s) across 3 worker topic(s)
RECOVERY: found interrupted job -- job for worker_3 (topic qrt_jobs__worker_3)
RECOVERY: completing it now...
RECOVERY: acked, done

=== phase 3: recovery is idempotent -- run it again, nothing left ===
RECOVERY: 0 leftover pending message(s)
true