jsonlines_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. package reader
  2. import (
  3. "bufio"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "math/rand/v2"
  10. "os"
  11. "path/filepath"
  12. "runtime"
  13. "strings"
  14. "testing"
  15. "github.com/google/uuid"
  16. )
  17. // jsonLinesOf marshals each of vals to its own line, returning a JSONL reader.
  18. func jsonLinesOf[T any](t *testing.T, vals []T, delimiter string) io.Reader {
  19. t.Helper()
  20. var sb strings.Builder
  21. for _, v := range vals {
  22. b, err := json.Marshal(v)
  23. if err != nil {
  24. t.Fatalf("marshaling test data: %v", err)
  25. }
  26. sb.Write(b)
  27. if delimiter != "" {
  28. sb.WriteString(delimiter)
  29. }
  30. }
  31. return strings.NewReader(sb.String())
  32. }
  33. // recordingCloser wraps a reader and records whether Close was called, returning
  34. // a configurable error from Close. Used to exercise the io.Closer branch.
  35. type recordingCloser struct {
  36. io.Reader
  37. closed bool
  38. closeErr error
  39. }
  40. func (rc *recordingCloser) Close() error {
  41. rc.closed = true
  42. return rc.closeErr
  43. }
  44. // TestJSONLinesReader_FoldedTerminalSignal proves the JSONL reader honors the
  45. // same folded-io.EOF contract as the array reader (folds when the final batch is
  46. // short; defers to (0, io.EOF) on an exact fill).
  47. func TestJSONLinesReader_FoldedTerminalSignal(t *testing.T) {
  48. tests := []struct {
  49. name string
  50. items int
  51. bufSize int
  52. wantReads []struct {
  53. n int
  54. eof bool
  55. }
  56. }{
  57. {"buffer larger than items", 3, 10, []struct {
  58. n int
  59. eof bool
  60. }{{3, true}}},
  61. {"buffer exactly fits items", 5, 5, []struct {
  62. n int
  63. eof bool
  64. }{{5, false}, {0, true}}},
  65. {"buffer unevenly divides items", 7, 3, []struct {
  66. n int
  67. eof bool
  68. }{{3, false}, {3, false}, {1, true}}},
  69. }
  70. for _, tc := range tests {
  71. t.Run(tc.name, func(t *testing.T) {
  72. r := NewJSONLinesReader[int](jsonLinesOf(t, seq(tc.items), "\n"))
  73. dst := make([]int, tc.bufSize)
  74. for i, want := range tc.wantReads {
  75. n, err := r.Read(context.Background(), dst)
  76. if n != want.n {
  77. t.Errorf("read %d: got n=%d, want %d", i, n, want.n)
  78. }
  79. if gotEOF := errors.Is(err, io.EOF); gotEOF != want.eof {
  80. t.Errorf("read %d: got eof=%v (err=%v), want eof=%v", i, gotEOF, err, want.eof)
  81. }
  82. if !want.eof && err != nil {
  83. t.Errorf("read %d: unexpected error: %v", i, err)
  84. }
  85. }
  86. })
  87. }
  88. }
  89. // TestJSONLinesReader_Whitespace verifies newline separation, blank lines,
  90. // trailing whitespace, CRLF, and empty input are all handled.
  91. func TestJSONLinesReader_Whitespace(t *testing.T) {
  92. tests := []struct {
  93. name string
  94. input string
  95. want []int
  96. }{
  97. {"one per line", "1\n2\n3\n", []int{1, 2, 3}},
  98. {"no trailing newline", "1\n2\n3", []int{1, 2, 3}},
  99. {"space separated", "1 2 3", []int{1, 2, 3}},
  100. {"crlf", "1\r\n2\r\n3\r\n", []int{1, 2, 3}},
  101. {"blank lines", "1\n\n\n2\n", []int{1, 2}},
  102. {"trailing whitespace", "1\n2\n \n", []int{1, 2}},
  103. {"empty", "", nil},
  104. {"all whitespace", " \n ", nil},
  105. }
  106. for _, tc := range tests {
  107. t.Run(tc.name, func(t *testing.T) {
  108. r := NewJSONLinesReader[int](strings.NewReader(tc.input))
  109. dst := make([]int, 2)
  110. var got []int
  111. for {
  112. n, err := r.Read(context.Background(), dst)
  113. got = append(got, dst[:n]...)
  114. if errors.Is(err, io.EOF) {
  115. break
  116. }
  117. if err != nil {
  118. t.Fatalf("unexpected error: %v", err)
  119. }
  120. }
  121. if len(got) != len(tc.want) {
  122. t.Fatalf("got %v, want %v", got, tc.want)
  123. }
  124. for i := range got {
  125. if got[i] != tc.want[i] {
  126. t.Errorf("item %d: got %d, want %d", i, got[i], tc.want[i])
  127. }
  128. }
  129. })
  130. }
  131. }
  132. // TestJSONLinesReader_TruncatedValue verifies a truncated trailing value is a
  133. // terminal, sticky, non-EOF error returned after the valid prefix — unlike the
  134. // array reader, whose lookahead swallows truncation.
  135. func TestJSONLinesReader_TruncatedValue(t *testing.T) {
  136. r := NewJSONLinesReader[int](strings.NewReader("1\n2\n{\"id\":"))
  137. dst := make([]int, 2)
  138. if n, err := r.Read(context.Background(), dst); n != 2 || err != nil {
  139. t.Fatalf("first read: got (%d, %v), want (2, nil)", n, err)
  140. }
  141. n, err := r.Read(context.Background(), dst)
  142. if n != 0 || err == nil || errors.Is(err, io.EOF) {
  143. t.Fatalf("second read: got (%d, %v), want (0, non-EOF error)", n, err)
  144. }
  145. // Sticky.
  146. if _, err2 := r.Read(context.Background(), dst); err2 != err {
  147. t.Errorf("third read: err=%v, want sticky %v", err2, err)
  148. }
  149. }
  150. // TestJSONLinesReader_MalformedValue verifies a wrong-type value mid-stream is a
  151. // terminal, sticky, non-EOF error.
  152. func TestJSONLinesReader_MalformedValue(t *testing.T) {
  153. r := NewJSONLinesReader[int](strings.NewReader("1\n{}\n3"))
  154. dst := make([]int, 4)
  155. // The batch stops at the malformed second value, returning the prefix.
  156. n, err := r.Read(context.Background(), dst)
  157. if n != 1 || err == nil || errors.Is(err, io.EOF) {
  158. t.Fatalf("read: got (%d, %v), want (1, non-EOF error)", n, err)
  159. }
  160. if dst[0] != 1 {
  161. t.Errorf("prefix item: got %d, want 1", dst[0])
  162. }
  163. if _, err2 := r.Read(context.Background(), dst); err2 != err {
  164. t.Errorf("next read: err=%v, want sticky %v", err2, err)
  165. }
  166. }
  167. // TestJSONLinesReader_StructValues exercises the realistic case: one JSON object
  168. // per line decoded into pointer elements across multiple batches.
  169. func TestJSONLinesReader_StructValues(t *testing.T) {
  170. type item struct {
  171. ID int `json:"id"`
  172. Name string `json:"name"`
  173. }
  174. src := []*item{{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}, {5, "e"}}
  175. r := NewJSONLinesReader[*item](jsonLinesOf(t, src, "\n"))
  176. dst := make([]*item, 2)
  177. var got []*item
  178. for {
  179. n, err := r.Read(context.Background(), dst)
  180. got = append(got, dst[:n]...)
  181. if errors.Is(err, io.EOF) {
  182. break
  183. }
  184. if err != nil {
  185. t.Fatalf("unexpected error: %v", err)
  186. }
  187. }
  188. if len(got) != len(src) {
  189. t.Fatalf("got %d items, want %d", len(got), len(src))
  190. }
  191. for i, p := range got {
  192. if p == nil || p.ID != src[i].ID || p.Name != src[i].Name {
  193. t.Errorf("item %d: got %v, want %+v", i, p, *src[i])
  194. }
  195. }
  196. }
  197. // TestJSONLinesReader_StructValues_NoNewline tests a file without newlines
  198. // between streamed JSON objects
  199. func TestJSONLinesReader_StructValues_NoNewline(t *testing.T) {
  200. type item struct {
  201. ID int `json:"id"`
  202. Name string `json:"name"`
  203. }
  204. src := []*item{{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}, {5, "e"}}
  205. r := NewJSONLinesReader[*item](jsonLinesOf(t, src, ""))
  206. dst := make([]*item, 2)
  207. var got []*item
  208. for {
  209. n, err := r.Read(context.Background(), dst)
  210. got = append(got, dst[:n]...)
  211. if errors.Is(err, io.EOF) {
  212. break
  213. }
  214. if err != nil {
  215. t.Fatalf("unexpected error: %v", err)
  216. }
  217. }
  218. if len(got) != len(src) {
  219. t.Fatalf("got %d items, want %d", len(got), len(src))
  220. }
  221. for i, p := range got {
  222. if p == nil || p.ID != src[i].ID || p.Name != src[i].Name {
  223. t.Errorf("item %d: got %v, want %+v", i, p, *src[i])
  224. }
  225. }
  226. }
  227. // TestJSONLinesReader_Close verifies the io.Closer source is closed.
  228. func TestJSONLinesReader_Close(t *testing.T) {
  229. rc := &recordingCloser{Reader: strings.NewReader("1\n2\n")}
  230. r := NewJSONLinesReader[int](rc)
  231. if err := r.Close(); err != nil {
  232. t.Errorf("Close: got err=%v, want nil", err)
  233. }
  234. if !rc.closed {
  235. t.Error("underlying source was not closed")
  236. }
  237. }
  238. // Simple struct with various data types for benchmarking JSONLinesReader
  239. type benchRecord struct {
  240. ID string `json:"id"`
  241. Name string `json:"name"`
  242. Value float64 `json:"value"`
  243. Labels map[string]string `json:"labels"`
  244. }
  245. // writeBenchJSONL writes n JSON-Lines records to path (one value per line).
  246. func writeBenchJSONL(tb testing.TB, path string, n int) {
  247. tb.Helper()
  248. f, err := os.Create(path)
  249. if err != nil {
  250. tb.Fatal(err)
  251. }
  252. defer f.Close()
  253. w := bufio.NewWriter(f)
  254. enc := json.NewEncoder(w) // Encode appends a newline after each value.
  255. for i := 0; i < n; i++ {
  256. rec := benchRecord{
  257. ID: uuid.NewString(),
  258. Name: fmt.Sprintf("name-%d", i),
  259. Value: rand.Float64(),
  260. Labels: map[string]string{"foo": "bar", "baz": "bat"},
  261. }
  262. if err := enc.Encode(&rec); err != nil {
  263. tb.Fatal(err)
  264. }
  265. }
  266. if err := w.Flush(); err != nil {
  267. tb.Fatal(err)
  268. }
  269. }
  270. // streamAll reads path in batches of batchSize, discarding every batch (dst is
  271. // reused, nothing retained), and returns the number of records seen.
  272. func streamAll(tb testing.TB, path string, batchSize int) int {
  273. tb.Helper()
  274. f, err := os.Open(path)
  275. if err != nil {
  276. tb.Fatal(err)
  277. }
  278. r := NewJSONLinesReader[benchRecord](f)
  279. dst := make([]benchRecord, batchSize)
  280. total := 0
  281. for {
  282. n, err := r.Read(context.Background(), dst)
  283. total += n
  284. // Discard: the next Read overwrites dst; no record outlives its batch.
  285. if errors.Is(err, io.EOF) {
  286. break
  287. }
  288. if err != nil {
  289. tb.Fatal(err)
  290. }
  291. }
  292. if err := r.Close(); err != nil {
  293. tb.Fatal(err)
  294. }
  295. return total
  296. }
  297. const (
  298. benchRecords = 100_000
  299. benchBatchSize = 1_000
  300. )
  301. // BenchmarkJSONLinesReader measures wall time and allocation churn to stream a
  302. // 50k-line JSONL file 1000 records at a time. With -benchmem, B/op is the TOTAL
  303. // bytes allocated per pass (churn, scales with record count) — not resident
  304. // memory. See BenchmarkJSONLinesReaderResident for the live-heap bound.
  305. func BenchmarkJSONLinesReader(b *testing.B) {
  306. path := filepath.Join(b.TempDir(), "bench.jsonl") // disposable; auto-removed
  307. writeBenchJSONL(b, path, benchRecords)
  308. b.ReportAllocs()
  309. b.ResetTimer()
  310. for i := 0; i < b.N; i++ {
  311. if got := streamAll(b, path, benchBatchSize); got != benchRecords {
  312. b.Fatalf("streamed %d records, want %d", got, benchRecords)
  313. }
  314. }
  315. }
  316. // BenchmarkJSONLinesReaderResident measures PEAK live heap (HeapInuse) while
  317. // streaming, which is what the streaming design is meant to bound: it should
  318. // stay ~flat at one batch's worth of records regardless of file size. Note the
  319. // per-batch runtime.ReadMemStats sampling perturbs timing, so read ns/op from
  320. // BenchmarkJSONLinesReader, not this one.
  321. func BenchmarkJSONLinesReaderResident(b *testing.B) {
  322. path := filepath.Join(b.TempDir(), "bench.jsonl")
  323. writeBenchJSONL(b, path, benchRecords)
  324. b.ResetTimer()
  325. var peakHeapInuse uint64
  326. for i := 0; i < b.N; i++ {
  327. f, err := os.Open(path)
  328. if err != nil {
  329. b.Fatal(err)
  330. }
  331. r := NewJSONLinesReader[benchRecord](f)
  332. dst := make([]benchRecord, benchBatchSize)
  333. for {
  334. n, err := r.Read(context.Background(), dst)
  335. _ = n
  336. var ms runtime.MemStats
  337. runtime.ReadMemStats(&ms)
  338. if ms.HeapInuse > peakHeapInuse {
  339. peakHeapInuse = ms.HeapInuse
  340. }
  341. if errors.Is(err, io.EOF) {
  342. break
  343. }
  344. if err != nil {
  345. b.Fatal(err)
  346. }
  347. }
  348. r.Close()
  349. }
  350. b.ReportMetric(float64(peakHeapInuse)/1024, "peakHeapKB")
  351. }