Skip to content

Add vortex-server + vortex-demo: live HTTP range demo - #377

Merged
dfa1 merged 17 commits into
mainfrom
feature/vortex-server-demo
Sep 12, 2026
Merged

Add vortex-server + vortex-demo: live HTTP range demo#377
dfa1 merged 17 commits into
mainfrom
feature/vortex-server-demo

Conversation

@dfa1

@dfa1 dfa1 commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Summary

Two new standalone, non-published demo modules for showing VortexHttpReader's partial-fetch story over plain HTTP -- built for a presentation on Vortex's HTTP range-read use case.

  • vortex-server (vortex-server.jar): a minimal, dependency-free object-storage HTTP server -- GET with byte-range support, HEAD, PUT, a plain-text object listing, and a /_stats endpoint. Exists because the JDK's own jwebserver (SimpleFileServer) doesn't implement Range requests at all; VortexHttpReader's first segment fetch against it throws VortexException because the returned byte count never matches the requested range.
  • vortex-demo (vortex-demo.jar): generates a synthetic tick dataset (timestamp/symbol/price/volume, 2M rows sorted by symbol), uploads it to a vortex-server (embedded by default, or an already-running one given on the command line for a two-process live demo), then runs a filtered + projected VortexHttpReader scan and reports bytes fetched over HTTP vs. the object's full size.

Live demo output (single-symbol filter, one-column projection, 22.4 MB file): 3.95% of the object fetched (883 KB), 131,072 matched rows.

Notable finding (not fixed here)

While building the demo I found a real gap in the reader: the default WriteOptions.globalDict=true silently defeats RowFilter zone-map pruning for Utf8 columns -- ScanIterator#canPruneChunk reads per-chunk embedded stats that the global-dict write path never populates, even though the separate zone-map stats table stays correct and is readable via ScanIterator#columnZoneStats. The demo works around it via WriteOptions#withGlobalDict(false). Flagging for a possible follow-up issue/fix.

Test plan

  • ./mvnw verify green across the whole reactor
  • server module: 10 tests, including two path-traversal regression tests (rejectsPathTraversalOnGet/rejectsPathTraversalOnPut) verified against literal, non-normalized URIs (raw sockets used during manual verification to rule out client-side ../ normalization masking a real gap)
  • Manual two-process run: java -jar server/target/vortex-server.jar <port> <dir> in one terminal, java -jar demo/target/vortex-demo.jar http://127.0.0.1:<port>/ in another

🤖 Generated with Claude Code

https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi

dfa1 and others added 17 commits September 12, 2026 20:59
…ange demo

Two new standalone, non-published modules:

- vortex-server: a minimal, dependency-free object-storage HTTP server
  (GET with byte-range support, HEAD, PUT, a plain-text object listing,
  a /_stats endpoint) standing in for real object storage in a demo.
  Exists because the JDK's own jwebserver (SimpleFileServer) does not
  implement Range requests at all -- VortexHttpReader's first segment
  fetch against it throws VortexException since the returned byte count
  never matches the requested range. Path-traversal guarded and covered
  by regression tests using literal (non-normalized) URIs, verified
  against both GET and PUT with raw sockets during development.

- vortex-demo: generates a synthetic tick dataset (timestamp/symbol/
  price/volume, sorted by symbol), uploads it to a vortex-server
  (embedded by default, or an already-running one given on the command
  line for a two-process live demo), then runs a filtered + projected
  VortexHttpReader scan against it and reports how many bytes were
  actually fetched over HTTP against the object's full size.

Along the way, found a real gap in the reader (not fixed here): the
default WriteOptions.globalDict=true silently defeats RowFilter
zone-map pruning for Utf8 columns, because ScanIterator#canPruneChunk
reads per-chunk embedded stats that the global-dict write path never
populates -- the separate zone-map stats table stays correct and is
readable via ScanIterator#columnZoneStats, but pruning doesn't consult
it. The demo works around it via WriteOptions#withGlobalDict(false).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
…ient

The server is conceptually part of the HTTP range demo, not a separate
top-level concern -- demo/ is now a small aggregator (vortex-demo-parent,
packaging=pom) with two children: demo/server (vortex-server, unchanged)
and demo/client (vortex-demo, unchanged). Artifact coordinates, package
names, and behavior are unchanged; only the directory layout moved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
…er vortex.demo.*

Splits the client's ad-hoc synthetic-data generation into its own
standalone CLI tool, vortex-fakedata-generator: given a compact
column-description grammar (name:type:generator(args), e.g.
"price:f64:range(50,150)"), inspired by PostgreSQL's generate_series,
it writes a Vortex file of synthetic data. Generators: series(start,step)
(the generate_series-style arithmetic progression), range(min,max),
normal(mean,stddev), enum(prefix,count) (random categorical labels),
constant(value), and bool(). A --sort-by flag clusters rows by a given
column afterward, independent of how that column's values were
generated -- useful for zone-map-pruning-friendly demo data.

demo/client (vortex-demo) no longer generates its own data: it now
takes an existing .vortex file via --file, uploads it to a vortex-server
(embedded by default, or --server for an already-running one), and
scans it with configurable --filter-column/--filter-value/--project.

All three demo modules' packages now nest under io.github.dfa1.vortex.demo:
vortex-server -> io.github.dfa1.vortex.demo.server,
vortex-fakedata-generator -> io.github.dfa1.vortex.demo.fakedata,
vortex-demo -> io.github.dfa1.vortex.demo.client. Reinforces that this
is all demo/presentation tooling, not part of the library's public API
surface.

Full three-tool pipeline verified end to end: generate -> serve -> scan,
2M rows sorted by symbol, single-symbol filter fetches 2.09% of the file
over HTTP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
Step-by-step for presenting: build once, two-terminal live run (server
log visibly showing small Range fetches vs. one big download), a
one-terminal convenience mode, and how to customize the filter/schema
for a different story. Cross-links issue #378 (the globalDict pruning
gap this demo surfaced) since it explains why the generator forces
globalDict=false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
A throttled (max 10 redraws/sec), in-place terminal progress bar
printed to stderr during the chunk-writing loop -- the I/O/encoding-
bound phase, unlike in-memory column materialization which is near-
instant even at millions of rows. Shows rows written, elapsed time,
and an ETA extrapolated from the current completion fraction. Verified
against a 50M-row generation (~11s) to see a real non-zero ETA.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
A virtual-thread background poller hits the server's /_stats endpoint
every 100ms while the scan runs, printing an in-place-updating "bytes
downloaded so far" line to stderr. Verified against a 20M-row/245MB
file: the counter genuinely climbs through real intermediate values
(0.8% -> 11.7% -> 26.8% -> 43.9% -> final 59.2%) rather than just
jumping straight to the final number -- the point being visible on
stage that the scan stops well short of the file's full size, not
just told after the fact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
…me-range filter

Now that #378/#379/#380 are fixed upstream, the workarounds and the
--sort-by knob are no longer needed:

- FakeDataGenerator no longer materializes every column fully in
  memory before writing (Object[] columnArrays + a post-hoc sort
  permutation). Generation is now truly one-chunk-at-a-time streaming:
  ColumnMaterializer.materialize takes a rowOffset so a `series(...)`
  column stays a correct arithmetic progression across chunk
  boundaries. Verified against 200,000,000 rows with a 512MB heap cap:
  completes in ~80s, no OutOfMemoryError (the old materialize-everything
  path OOM'd on this same input). The progress bar now genuinely
  tracks the whole run instead of jumping straight to 100% after a
  silent materialization phase.
- --sort-by is gone: real data never arrives pre-sorted by whatever
  column a later query filters on, so faking that clustering
  misrepresented the workload. The withGlobalDict(false) workaround
  for #378 is gone too, now that the fix landed.
- vortex-demo's default filter switches from symbol equality
  (meaningless without --sort-by -- an enum() column is scattered
  uniformly across every chunk by design) to a numeric range on
  timestamp: a series(...) column is naturally ordered by row
  position with no sorting needed, matching real append-order
  ingestion. New --filter-min/--filter-max flags replace
  --filter-value.

End-to-end verified on the default 2,000,000-row example: 5.55% of
the file fetched over HTTP, real compression (cascading(3), the
generator's own default), zero workarounds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
…flow)

lastDrawNanos started at Long.MIN_VALUE. The very first #update call
computed `nowNanos - lastDrawNanos` -- a moderate positive nanoTime()
value minus the most negative possible long -- which silently
overflows a signed long and wraps to a negative Duration. A negative
duration always compares as "less than the 100ms redraw interval", so
the first (and every subsequent, since lastDrawNanos never advances
away from MIN_VALUE either) non-final update got throttle-skipped for
the entire run. Only the final call ever drew, since it bypasses the
throttle check entirely -- exactly matching what looked like "the bar
does nothing until the very end".

Fixed by backdating the initial value by one redraw interval instead
of using a sentinel, avoiding the overflow entirely (both operands
stay close to System.nanoTime()'s own range).

Verified empirically, not just reasoned about: polled the output
file's size every second during a 200M-row run. Before the fix, it
sat at 0 bytes for the entire ~65s run and jumped to ~60KB in one
write at the very end. After, it grows continuously and roughly
linearly throughout (0 -> 859 -> 1750 -> ... -> 60973 bytes). Also
confirmed, by temporarily removing it and rerunning the same test,
that an explicit System.err.flush() per redraw was never actually
necessary -- PrintStream#write(String) already pushes every write
through its internal encoding buffers unconditionally, and the
underlying FileOutputStream has no buffering of its own to flush.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
… progress redraws

ColumnMaterializer.materializeUtf8's enum(prefix,count) generator was
calling String.formatted() once per row to build a zero-padded label,
even though only `count` distinct labels ever exist. Measured: ~35%
of total generation time for a 200M-row run with a 30-value enum
column was attributable to this one column (67s -> 44s with it
removed). Precomputing the small label pool once and indexing into it
per row instead: 67.3s -> 57.5s for the same run (~15% faster
overall; the remaining gap vs. the no-enum baseline is real write-side
cost of encoding a 4th column, not a generation-side inefficiency).

Also: ProgressBar's redraws didn't erase a longer previous line (\r
only returns the cursor to column 0, it doesn't clear anything), so a
shrinking field -- the ETA's digit count dropping as it counts down,
for instance -- left trailing characters on screen (observed live:
"eta=51s" rendered as "eta=51ss"). Now pads every redraw to the
longest line drawn so far.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
Every run re-uploaded the file via PUT, even when iterating on
--filter-column/--filter-min/--filter-max/--project against a file
already sitting on the server from a previous run -- wasteful and
slow for exactly the "try different queries" workflow this client is
meant to support. --no-upload skips the PUT and queries the existing
object directly; requires --server since an embedded server always
starts empty. Verified: two runs against the same file, second with
--no-upload and a different filter, server log shows exactly one PUT
total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4ijFsGW1MHEcGiu26vNzi
…file argument

Replaces --file/--server/--no-upload flags with two clear modes:
--upload FILE SERVER_URL copies a file and exits; a query takes a
remote object URL directly (no upload, no local file at all -- file
size comes from VortexHandle#fileSize on the opened reader) or a
local file path (embeds a server, uploads, then queries), dropping
the separate --file/--server/--no-upload flag combination entirely.
A 404/connection failure isn't a syntax problem -- printing the usage
block after it (as if the command line were wrong) was misleading.
Only IllegalArgumentException (missing/unknown argument) shows usage
now; other failures print just the error message.
…ve paths

"vortex-server 8080 ." (a relative dataDir) served a correct directory
listing but 404'd every single-object GET/HEAD: #resolve normalized
the resolved candidate before its startsWith containment check, but
never normalized dataDir itself, so a bare "." never structurally
startsWith-matched a candidate that normalize() had already stripped
the "." from. Normalizing dataDir to an absolute path once at start()
fixes it for every relative form, not just ".".
…sizes

ScanOptions#withFilter only prunes whole chunks via zone-map stats --
it never filters individual rows within a surviving chunk. Summing
chunk.rowCount() therefore reported every row in every non-pruned
chunk as "matched", regardless of whether it actually satisfied the
filter. A price:range(50,150) filter of [500,501] -- outside the
column's entire range -- was reporting all 200,000,000 rows as
matched, live on the user's own machine.

Compute#filteredAggregate(chunk, filter, null) runs the same filter
row-by-row (fused single pass, COUNT(*)-style with no aggregate
column) to get the true matched-row count. Verified against the
user's real 2.4GB/200M-row file: a [100,102] filter over a uniform
[50,150] price column now reports 3,996,842 matched rows, ~2% of
200M as expected, instead of 200,000,000.
price never prunes regardless of filter overlap, unlike the "no
natural clustering" story that explains symbol/enum columns -- worth
distinguishing since it looks the same in the demo's own output
(same ~54% either way) but has a different, currently-open root
cause.
…range

One compact COLUMN:MIN:MAX argument instead of three separate flags to
remember for a live demo; --project stays separate since it's optional.

Fixes #383.
Rebasing onto main pulled in the #382 fix. Verified live: a price filter
entirely outside the data's range now prunes to <1% of the file instead of
the ~54% it fetched when ALP-RD reported no min/max at all. A filter that
overlaps price's range still touches most chunks, but that's the column's
lack of natural clustering, not the stats bug.
@dfa1
dfa1 force-pushed the feature/vortex-server-demo branch from 1082424 to a141f7a Compare September 12, 2026 19:02
@dfa1
dfa1 merged commit ac378b9 into main Sep 12, 2026
6 checks passed
@dfa1
dfa1 deleted the feature/vortex-server-demo branch September 12, 2026 19:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant