SCUA

News

SCUA can now read a file of any size

September 2, 2026

SCUA reads a file of any size in a fixed amount of memory. Counting the lines in a 124 MB log takes about 11 MB, and that figure stays flat as the file grows: a gigabyte costs the same as a megabyte. fs.open hands you a live file handle you pull the file through a piece at a time, so what you hold at any moment is one line or one block.

File size and memory are now separate questions. A single SCUA value is capped at 256 MiB, which is a deliberate bound on one object rather than on your data, and streaming means the size of a file no longer runs into it. A log-processing tool can work on whatever it is pointed at.

#What it looks like

import fs
import str

match fs.open("access.log")
  Ok(f) -> do
    let errors = 0
    let more = true
    while more do
      match fs.read_line(f)
        Ok(line) ->
          if line == nil then
            more = false
          else
            if str.contains(line, "ERROR") then errors = errors + 1 end
          end
        Error(why) -> do
          print(`read failed: {why}`)
          more = false
        end
      end
    end
    print(`{errors} errors`)
    fs.close(f)
  end
  Error(why) -> print(`could not open: {why}`)
end

fs.read_chunk(f, max) is the same shape for raw bytes, which is what you want for binary files or when a line is not the unit you care about.

#The two readers end differently, on purpose

fs.read_chunk is finished when it gives you an empty block, matching how reading from a network connection, an HTTP response and a child process already work in SCUA.

fs.read_line is finished when it gives you nil, and that difference is deliberate. An empty line is a real line. A blank row in a CSV, a paragraph break, the record separator in half the world's log formats. If an empty string meant "finished", a reader would stop at the first blank line in your file and never say so, and you would get a wrong answer that looked like a right one. So the line reader gets its own signal.

One consequence worth knowing before you reach for it: fs.read_line folds Windows line endings to \n, the same as fs.read_text does. That is convenient when you are reading text and wrong when you are rewriting a file, because a Windows-formatted file read line by line and written back loses every carriage return. When you need the bytes exactly as stored, use fs.read_chunk, which changes nothing. The rule across the module is that text verbs give you \n and byte verbs give you what is on disk.

#Writing works the same way

match fs.open("report.csv", { mode = "write" })
  Ok(f) -> do
    fs.write_chunk(f, "id,name,total\n")
    fs.close(f)
  end
  Error(why) -> print(`could not open: {why}`)
end

"write" starts a new file or empties an existing one. "append" continues an existing one, or starts it if it is not there yet, so a log's first run needs no special case. fs.write is still there for content you already hold in one piece; fs.write_chunk is for output you produce as you go, which is what a transform or a report generator does.

Writes are not buffered. Each one goes to disk, so a failure is reported by the call that caused it rather than surfacing later when you close the file. When one fails partway, the error tells you how many bytes got through, and the file is exactly its previous length plus that many. So you know where the file got to, rather than having to discard it and start again. The trade is that a chunk is a trip to the operating system, so build up a few kilobytes and write that rather than one short line at a time.

#Closing files, and snapshots

Close what you open. Nothing enforces it while a program runs. Files are closed when the program ends, but a loop that opens files without closing them will exhaust the operating system's file descriptors, and the failure then lands somewhere with no obvious connection to the file you forgot: a network connection that will not open, a log that will not write. If a loop opens a file, put the close in the same loop.

A program holding an open file cannot be snapshotted. A file descriptor does not survive being saved and reloaded, so SCUA refuses rather than writing a snapshot that comes back pointing at nothing. If you are writing a handler that needs to survive a snapshot, finish with the file and close it before the handler ends. The refusal now names the handle that caused it.

#Under async I/O

With --io=async, a read hands the worker back to other tasks rather than holding it, so one handler reading a large file does not stall the others. Line reading yields when it needs more data from disk rather than on every line, which keeps the cost of yielding close to the cost of not yielding: a handler streaming a file runs at about the speed it would if it never gave the worker up.

#Where to start

The how-to page reading a file too big to load opens with a table of which reader to use for what, which is the first thing most people need. examples/streaming_files.scua reads a file line by line, reads it again as bytes, copies it through a second handle, and shows what each refusal looks like.