diff --git a/di/pqx/VERSION b/di/pqx/VERSION new file mode 100644 index 00000000..6c6aa7cb --- /dev/null +++ b/di/pqx/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/di/pqx/init.q b/di/pqx/init.q new file mode 100644 index 00000000..543ecb56 --- /dev/null +++ b/di/pqx/init.q @@ -0,0 +1,20 @@ +/ KDB-X Parquet extract module to save kdb+ data to parquet storage convention + +arrow:use`kx.arrow +pq:use`kx.pq +pqt:use`kx.pq.t + +\l ::pqx.q + +/ module version, read from the VERSION file (one plain-text file to bump per release). read +/ module-relative at load (`:::` resolves to di/pqx) and BEFORE export, since export:([...]) +/ evaluates each name; version stays in the export so di.depcheck reads it from the export dict. +/ trim, and fail LOUD on a missing/unreadable/empty VERSION, rather than a bare `first read0`: +/ a raw OS error names no module, and an empty or whitespace-padded value is worse than an error - +/ di.depcheck compares versions as STRINGS, so padding silently breaks the comparison and an empty +/ value reads as "exports no version", failing every dependent module's check for a reason that +/ points nowhere near the real cause. +version:@[{trim first read0 x};`:::VERSION;{'"di.pqx: VERSION file missing or unreadable"}]; +if[0=count version;'"di.pqx: VERSION file is empty"]; + +export:([init;extract;getmanifest;checkandconvertcols;estimate;plan;writefile;readfile;tryfn;buildvirtualtable;castvirtualcol;checkvirtuallevels;version]) diff --git a/di/pqx/pqx.md b/di/pqx/pqx.md new file mode 100644 index 00000000..f6ca66a8 --- /dev/null +++ b/di/pqx/pqx.md @@ -0,0 +1,393 @@ +# di.pqx + +Converts an in-memory kdb+ table into one or more `.parquet` files via `kx.arrow`. Rows are grouped +by instrument and packed into files close to a configurable target size, splitting any single +oversized instrument across multiple files where required. A manifest recording what was written +(file, instruments, row count, time range, on-disk size) is accumulated in the module's private +`manifest` table; `extract` also returns this same information for the files it just wrote. + +--- + +## Features + +- Splits a table into one or more `.parquet` files targeting a configurable file size +- Groups rows by instrument so a single instrument's data is not split across files unless it alone exceeds the target size +- Optionally splits any oversized single instrument across multiple files +- Optionally calibrates the raw-to-parquet size ratio with a trial write, or uses a fixed ratio +- Optionally pre-sorts input data by instrument/time before writing +- Optionally partitions output into Hive-style `=/` subdirectories via `virtualcols`, one file per combination, ahead of the size-based bucketing above - the partitioned columns are dropped from the on-disk data and can be reconstructed from the path with `readfile` +- Writes files sequentially or in parallel (`peach`) +- Accumulates a manifest of every file written, including row counts, instrument lists, time bounds and on-disk size +- Builds a queryable virtual table over a directory of previously-written `.parquet` files, without reading any of their data up front - `date`/`virtualcols` values are reconstructed straight from each file's path + +--- + +## Dependencies + +| Dependency | Key | Required | Description | +|---|---|---|---| +| logger | `` `log `` | yes | dict with `info`, `warn`, and `error`, each binary `{[c;m]}` where `c` is a symbol context and `m` is a string | + +**Hard dependencies:** `kx.arrow` and `kx.pq` — both loaded automatically (via `use`) when `di.pqx` +is imported, before `pqx.q` itself is loaded. `extract`/`readfile` call +`` .m.di.0pqx.arrow.pq.writeParquetFromTable ``/`` .m.di.0pqx.arrow.pq.readParquetToTable `` to +perform every write/read; `buildvirtualtable` calls `` .m.di.0pqx.pq.pq `` (per-file virtual table) +and `` .m.di.0pqx.pqt.mkP `` (from `` kx.pq.t ``, loaded separately as `pqt`) to compose the +multi-file view. + +Both modules must be resolvable on the process's module search path at that point. Where either is +installed as a conda package (e.g. under a `kx.qmamba`-managed root such as +`~/.kx/root/lib/q/mod`), that location needs to already be on `QPATH` — `di.pqx` does not load +`kx.qmamba` itself to arrange this. Confirm `kx.arrow`, `kx.pq`, and `kx.pq.t` each load standalone +(`` use`kx.arrow ``, `` use`kx.pq ``, `` use`kx.pq.t ``) in the target environment before relying on +`di.pqx` there. `kx.arrow`'s bundled `libcurl` may also need a newer OpenSSL than the system default +on the `LD_LIBRARY_PATH` — if `use\`kx.arrow` fails with an `OPENSSL_*` symbol-version error, point +`LD_LIBRARY_PATH` at the `lib` directory the conda-installed `kx.arrow`/`kx.pq` packages ship +alongside it. + +The `log` dependency must be passed to `init` inside a dict keyed on `` `log ``. `init` throws +immediately if `log` is absent, is not a dict, or is missing any of `info`/`warn`/`error`. The value +must already conform to the binary `{[c;m]}` contract — `init` performs no adaptation, so a raw +monadic `kx.log` instance must be wrapped by the caller first. Build the dict from `di.log`, or +hand-roll one. + +```q +logger:use`di.log +logdep:`info`warn`error!(logger.info;logger.warn;logger.error) +pqx:use`di.pqx +pqx.init[enlist[`log]!enlist logdep] + +/ or, skipping the by-hand dict: +/ pqx.init[logger.logdict] +``` + +--- + +## Options + +Passed as the `o` dictionary to `extract`, merged over the module's own `default` dict. Any keys +omitted from `o` fall back to the default shown below. + +| Key | Default | Type | Description | +|---|---|---|---| +| `targetsize` | `512*1024*1024` | long | Target size in bytes for each output file | +| `maxfactor` | `1.5` | float | Hard cap on file size, expressed as a multiple of `targetsize` | +| `splitoversized` | `1b` | boolean | Split any single instrument larger than the cap across multiple files | +| `calibrate` | `1b` | boolean | Run a trial write to measure the raw-to-parquet size ratio instead of using `compressionratio` | +| `onesymperfile` | `0b` | boolean | Write exactly one file per instrument, ignoring the target-size bucketing/packing logic. Forces `splitoversized` off (see below) | +| `compressionratio` | `0.30` | float | Raw-to-parquet size ratio used for size estimation when `calibrate` is `0b` | +| `symcol` | `` `sym `` | symbol | Instrument column | +| `timecol` | `` `time `` | symbol | Time column | +| `presort` | `1b` | boolean | Sort input by `` (symcol;timecol) `` before writing | +| `rowgroupbytes` | `128*1024*1024` | long | Reserved for future use — not currently read by the write path | +| `codec` | `` `zstd `` | symbol | Compression codec, upper-cased and applied to the writer's `` `COMPRESSION `` option | +| `complevel` | `3` | long | Reserved for future use — not currently read by the write path | +| `virtualcols` | `` `symbol$() `` | symbol list | Virtual, path-only partition columns. When non-empty, takes precedence over `onesymperfile`/`splitoversized`: one file is written per distinct combination of these columns' values, forcing both of those off for the call | +| `parallel` | `0b` | boolean | Write files with `peach` instead of `each` | +| `outdir` | `` `:. `` | symbol | Root output directory | +| `filestub` | `"part"` | string | File name stub; files are written as `-NNNNN.parquet` | + +Output files are written to `//date=
/-NNNNN.parquet`. `extract` throws +(`` `di.pqx: input keys not recognised - ... ``) if `o` contains any key not present in the module's +`default` dict — this is the very first check `extract` performs, before the empty-table check. It +also throws (`` `di.pqx: no symcol found in table `` / `` `di.pqx: no timecol found in table ``) if +the merged `symcol`/`timecol` is not a column of the input table — this check runs unconditionally, +regardless of `presort`. It also throws (`` `di.pqx: cannot extract from empty table ``) if `t` has +zero rows, regardless of `calibrate` — this check runs before the column checks but after the input +key check. + +When `onesymperfile` is `1b`, each instrument is written to its own file regardless of `targetsize` +bucketing, and `splitoversized` is forced to `0b` for that call (an oversized single instrument is +still written to one file, not split, even if `splitoversized:1b` is also passed in `o`). + +When `virtualcols` is non-empty, it takes precedence over both `onesymperfile` and `splitoversized` +(both are forced to `0b` for that call, regardless of what was passed in `o`), and `targetsize`/ +`maxfactor` bucketing does not apply: every distinct combination of the `virtualcols` columns' values +becomes exactly one output file, however large. `extract` throws (`` `di.pqx: not all virtualcols +found in table ``) if any `virtualcols` column is not present in the input table. Output paths gain one +`=/` segment per `virtualcols` column, Hive-style, e.g. +`//date=
/exchange=NASDAQ/-NNNNN.parquet` — the file's combination is also +recorded directly in the manifest's `virtualcols` column (see Manifest Schema below). The `virtualcols` +columns themselves are dropped from the on-disk data before writing (their value is already fixed by the +path, so keeping them in every row would just be redundant storage) — use `readfile` (see below) to +read a file back with its `virtualcols` values (and the `date` partition) reattached as columns. + +--- + +## Compression codecs + +For the codec option, the supported compression codec values are: + +| Codec | Notes | +|---|---| +| UNCOMPRESSED | no compression | +| SNAPPY | fast, moderate compression; widely compatible, common default choice in the Arrow/Parquet ecosystem | +| GZIP | slower but generally better compression ratio than Snappy | +| BROTLI | tends to give strong compression ratios, more CPU-intensive | +| ZSTD | good balance of speed and compression ratio, popular modern choice; `di.pqx`'s own default (see Options) | +| LZ4 | very fast, lower compression ratio | +| LZ4_RAW | raw LZ4 frame variant (no LZ4 frame header/checksum overhead) | +| LZ4_HADOOP | LZ4 variant compatible with the Hadoop ecosystem's framing | +| LZO | fast compression, less common ratio-wise | +| BZ2 | higher compression ratio, slower, less commonly used with Parquet | + +Availability depends on how the underlying libarrow build was compiled — arrowkdb's docs note that "the libarrow build being used must include the corresponding libraries" for a given codec, so not every codec is guaranteed to work in every environment. + +--- + +## Manifest Schema + +The module's `manifest` table accumulates one row per file written across all `extract` calls; call +`getmanifest[]` to read the full accumulated table. `extract` itself returns a table of the same +shape, scoped to only the file(s) written by that call. + +| Column | Type | Description | +|---|---|---| +| `file` | symbol | Path written | +| `seq` | long | Sequence number within the partition | +| `virtualcols` | symbol list | The `virtualcols` option value in effect for this call - the list of columns partitioned into this file's path, not the combination's values themselves (see Options) | +| `syms` | symbol list | Instruments contained in the file | +| `nsyms` | long | Count of instruments in the file | +| `rows` | long | Row count | +| `mintime` | timestamp | Minimum time across the file (for pruning) | +| `maxtime` | timestamp | Maximum time across the file | +| `estbytes` | long | Estimated size at plan time | +| `bytes` | long | Actual on-disk size | +| `split` | boolean | `1b` if this file is a chunk of a split oversized instrument | +| `status` | symbol | `` `ok `` or `` `error `` | + +In addition to the in-memory `manifest`, each `extract` call writes this same per-call stats table +to a `manifest.json` sidecar file directly under the partition directory (i.e. +`//date=
/manifest.json`), serialized to a single line of JSON with `.j.j` and +written with `0:`. A repeat `extract` call into the same partition overwrites the sidecar with just +that call's rows, rather than accumulating across calls — the sidecar mirrors `extract`'s return +value, not `getmanifest[]`. Writing the sidecar is best-effort: if it fails (for example a +permissions issue, or something else already occupying that path) a warning is logged but `extract` +still returns normally and still updates the in-memory `manifest`. + +Read it back with `` .j.k first read0 hsym `$"//date=
/manifest.json" ``. JSON has +no native date/timestamp/symbol type, so the round trip is not type-preserving: `file`, `syms`, +`status` and `virtualcols` come back as plain strings (cast back with `` `$ ``) and `mintime`/`maxtime` +come back as ISO-8601 strings rather than timestamps; numeric columns (`seq`, `nsyms`, `rows`, +`estbytes`, `bytes`) come back as floats rather than longs (cast back with `` "j"$ ``) and `split` +comes back as a native JSON boolean unchanged. + +--- + +## Virtual Tables + +`buildvirtualtable` opens every `.parquet` file under `//` as a single queryable +table, without reading any row data up front - it's the read-side counterpart to `extract`'s output +layout. Each file's `date=
/` segment (and any `=/` `virtualcols` segments) is +reconstructed from its path into a virtual column, exactly mirroring what `extract`/`writefile` +stripped from the on-disk data on the way in. Filtering on those virtual columns (e.g. +`` select from vt where date=2025.07.15,exch=`NASDAQ `` ) prunes to just the matching files rather +than scanning everything. + +The returned value is not a regular in-memory kdb+ table - it's a functional/composed object from +`kx.pq.t`'s `mkP`. `select` works on it directly; `exec`/`meta`/`cols` do not work applied directly +to it, only to a `select` (or `meta`) result taken from it first (e.g. +`` exec c from meta vt `` to list columns, `` exec count i from select from vt `` to count rows, +not `` count vt `` or `` cols vt ``). + +Genuine on-disk columns keep whatever type they were written with - notably, character/symbol +columns come back as **strings**, not symbols, since Parquet has no native symbol type (see +`checkandconvertcols`). Only the path-reconstructed `date`/`virtualcols` columns come back typed as a +real date/symbol. + +### `buildvirtualtable[hdbdir;tname;datecol;virtualcols]` +Find every `.parquet` file under `//` and compose them into one virtual table, +partitioned by the `date=
/` segment and any `virtualcols` segments found in each file's path. + +| Parameter | Type | Description | +|---|---|---| +| `hdbdir` | symbol (hsym) | Root directory - matches `extract`'s `outdir` | +| `tname` | symbol | Table name - matches `extract`'s `tname` | +| `datecol` | symbol | Name to give the reconstructed date partition column (need not be literally `` `date `` ) | +| `virtualcols` | symbol list | Names of any `virtualcols` path segments to reconstruct, in path order - matches `extract`'s `virtualcols`. Pass `` `symbol$() `` if the data was written without `virtualcols` | + +```q +vt:pqx.buildvirtualtable[`:./;`trade;`date;enlist`exchange] +select from vt where date=2025.07.15,exchange=`NASDAQ +``` + +Building the table succeeds even if no files match (an empty view); querying that empty view then +fails, rather than silently returning zero rows. `virtualcols` here does not need to match a prior +`extract` call exactly - it only needs to match the path segments actually present under +`hdbdir/tname/date=.../`. When files do exist, `datecol`/`virtualcols` are checked (via +`checkvirtuallevels`) against the hive-style `key=value` directory levels actually found on disk, +both in count and in name/order - a declared `datecol`/`virtualcols` combination that's missing a +level, has an extra one, misnames one, or gets the order wrong fails loudly here instead of silently +mislabeling columns or dropping partition levels from the resulting table. Passing a `virtualcols` +name that collides with a genuine on-disk column (rather than one `extract` actually stripped to the +path) still produces unreliable results — the two columns are not distinguished internally, unlike on +the write side where `writefile` always strips the real column first. + +--- + +## Initialisation + +`init[deps]` wires the injected `log` dependency and must be called before the first `extract`. It +does not touch the parquet writer — the `PARQUET_VERSION`/`COMPRESSION` write options are built +fresh inside every `extract` call, and `kx.arrow` is loaded automatically by the module itself (see +Dependencies). + +```q +pqx:use`di.pqx +logdep:`info`warn`error!({[c;m]};{[c;m]};{[c;m]}) +pqx.init[enlist[`log]!enlist logdep] +``` + +--- + +## Exported Functions + +| Function | Description | +|---|---| +| `init[deps]` | Wire the injected `log` dependency. Call once before the first `extract`. | +| `extract[t;tname;dt;o]` | Write a table out to one or more parquet files, appending one row per file to the module's `manifest`. Returns that same per-file stats table, scoped to this call. | +| `getmanifest[]` | Return the manifest accumulated so far across all `extract` calls. | +| `readfile[path;readopt]` | Read a single file back, reattaching its `virtualcols`/`date` values reconstructed from its path (see Options). | +| `buildvirtualtable[hdbdir;tname;datecol;virtualcols]` | Compose every file under `hdbdir/tname/` into one queryable virtual table, with `date`/`virtualcols` reconstructed from each file's path (see Virtual Tables). | +| `version` | The module version string, read from the `VERSION` file at load time. Consumed by `di.depcheck`. | + +The remaining exports — `checkandconvertcols`, `estimate`, `plan`, `writefile`, `tryfn`, +`castvirtualcol`, `checkvirtuallevels` — are internal pipeline steps of `extract`/`buildvirtualtable`, +exposed only so `k4unit` can exercise them directly. Call `extract`/`buildvirtualtable` for normal +use. + +### `init[deps]` +Validate the required `log` dependency and store it for use by every other function. + +| Arg | Type | Description | +|---|---|---| +| `deps` | dict | Must contain `` `log `` → `` `info`warn`error!(infofn;warnfn;errfn) `` | + +Throws (prefixed `di.pqx:`) if `deps` is not a dict, `log` is missing, or the log dict lacks any +required key. + +### `version` +The module version string, read from the `VERSION` file at load time (fails loudly if that file +is missing, unreadable, or empty). Consumed by `di.depcheck`. +```q +pqx.version / "0.1.0" +``` + +### `extract[t;tname;dt;o]` +Write table `t` out to one or more parquet files under `//date=
/`, appending one +row per file written to the module's `manifest` and returning that same set of rows (see Manifest +Schema) scoped to this call only — it does not include rows from any earlier `extract` call. `o` is +merged over `default` (see Options). The output directory is created before the +size-estimation/calibration step, so a fresh `outdir` works with the default `calibrate:1b`. + +| Parameter | Type | Description | +|---|---|---| +| `t` | table | Data to write | +| `tname` | symbol | Table name — used in the output path | +| `dt` | date | Partition date — used in the output path | +| `o` | dict | Option overrides, merged over `default` | + +```q +pqx.extract[trade;`trade;2025.07.15;`targetsize`codec!(256*1024*1024;`gzip)] +``` + +### `readfile[path;readopt]` +Read a single parquet file back via `` .m.di.0pqx.arrow.pq.readParquetToTable ``, then reattach any +values that `extract` stripped from the on-disk data and encoded only in the file's path — the +`date=
` partition segment (reconstructed as a date) and any `virtualcols` combination segments +(each reconstructed as a symbol). Every `col=value` segment found in `path` becomes a column in the +returned table, broadcast as a constant across every row. `path` may be a plain string or an hsym, +with or without a leading colon; `readopt` is passed straight through to the underlying reader (e.g. +`` (0#`)!() `` to read every column). + +| Parameter | Type | Description | +|---|---|---| +| `path` | string or symbol | Path to a single file, as recorded in a manifest `file` value | +| `readopt` | dict | Options passed through to `` .m.di.0pqx.arrow.pq.readParquetToTable `` | + +```q +f:1_string first exec file from pqx.getmanifest[] where file like "*exchange=NASDAQ*" +pqx.readfile[f;(0#`)!()] +``` + +Only useful for a file written with `virtualcols` set, or to recover the `date` — a file written without +`virtualcols` has nothing to reconstruct beyond `date`, since no other columns were stripped from it. + +--- + +## Usage Example + +```q +// Include pqx module in a process +pqx:use`di.pqx + +// Wire the log dependency (once per process) +logger:use`di.log +pqx.init[logger.logdict] + +// Write `trade` for 2025.07.15, overriding the target file size and codec +res:pqx.extract[trade;`trade;2025.07.15;`targetsize`codec!(256*1024*1024;`gzip)] + +// res holds only the row(s) written by this call +res + +file seq virtualcols syms nsyms rows mintime maxtime estbytes bytes split status +------------------------------------------------------------------------------------------------------------------------------------------------------------------------ +:./trade/date=2025.07.15/part-00001.parquet 1 `symbol$() `AAPL`MSFT 2 50000 2025.07.15D00:00:00.000000000 2025.07.15D23:59:59.000000000 1153433 1048576 0b ok + +// getmanifest[] returns the full accumulated table across every extract call so far +pqx.getmanifest[] +``` + +--- + +## Running Tests + +```q +k4unit:use`di.k4unit +k4unit.moduletest`di.pqx +``` + +`test.csv` drives `extract` across default and overridden options — presort on/off, an oversized +instrument with `splitoversized` on and off, a `symcol` override, parallel (`peach`) writes, and a +custom `filestub`/non-default codec — then asserts on the resulting `getmanifest[]` rows and (via +`` .m.di.0pqx.arrow.pq.readParquetToTable ``) the files written back to disk. It also covers a +zero-row table and a table missing `symcol`/`timecol` (both fail outright), and an invalid `codec` +(degrades gracefully — see Manifest Schema's `status` column). + +It also builds `buildvirtualtable` views over several of those same `extract` outputs — no +`virtualcols`, a single `virtualcols` level, and two `virtualcols` levels (one overlapping `symcol` itself) — +asserting row counts, virtual-column types, and that filtering on a virtual column prunes to the +right file(s). `castvirtualcol` is exercised directly for both the datecol and non-datecol cases, +including a datecol not literally named `` `date `` . A directory with no matching files is covered +too: building the view over it succeeds, but querying it then fails. + +`checkvirtuallevels` - the validation `buildvirtualtable` runs against every discovered file's path - +is covered both indirectly, via `buildvirtualtable` calls that omit a real on-disk level, misname +one, or supply `virtualcols` in the wrong order (all expected to fail), and directly, asserting it +passes a matching `datecol`/`virtualcols` combination and fails one with too few declared levels, a +misnamed level, or files that disagree with each other on partition depth. + +It also covers the `manifest.json` sidecar directly — parsing it back with `.j.k`/`read0` and casting +its columns against `extract`'s returned per-call stats table (see Manifest Schema), that a repeat +`extract` call into the same partition overwrites rather than accumulates the sidecar, and that a +sidecar write failure (something else already occupying that path) still lets `extract` return +normally and update the in-memory `manifest`. + +--- + +## Notes + +- `rowgroupbytes` and `complevel` are accepted in `default` and any `o` override, but nothing in the + current write path reads them — only `` `PARQUET_VERSION `` (fixed at `` `V2.LATEST ``) and + `` `COMPRESSION `` (from `codec`) are passed to the writer. `virtualcols` (see Options) is read by + `plan`/`estimate`/`writefile` for file planning and output paths only. +- `symcol`/`timecol` presence is validated unconditionally on every `extract` call, even when + `presort` is `0b`. A zero-row input table is rejected outright, before that check, regardless of + `calibrate`. +- A per-file write failure (e.g. an invalid `codec`) is caught and logged at `warn`, and that file + is recorded with `` status=`error `` (and `bytes:0`) in the manifest — it does not abort the rest of + `extract`. +- `buildvirtualtable` has only been tested pointed at a single `date=` partition at a time; pointing + it at `hdbdir/tname/` directories that span multiple dates is expected to work (the `date` segment + is reconstructed the same way as any other level) but isn't covered by `test.csv` yet. diff --git a/di/pqx/pqx.q b/di/pqx/pqx.q new file mode 100644 index 00000000..58de8a08 --- /dev/null +++ b/di/pqx/pqx.q @@ -0,0 +1,442 @@ +/ define default config +default:( + `targetsize`maxfactor`splitoversized`calibrate`onesymperfile`compressionratio, + `symcol`timecol`presort`rowgroupbytes`codec`complevel`virtualcols, + `parallel`outdir`filestub + )!( + 512*1024*1024; / ~512 MB target file size + 1.5; / hard cap = target * maxfactor + 1b; / split instruments larger than the cap + 1b; / run a calibration write to set the ratio + 0b; / enforce a single instrument per file + 0.30; / raw->parquet ratio if not calibrating + `sym; / instrument column + `time; / time column + 1b; / sort by (sym,time) if not already + 128*1024*1024; / ~128 MB row groups + `zstd; / codec + 3; / compression level + `symbol$(); / virtual (path-only) partition columns, taking precedence over onesymperfile/splitoversized + 0b; / write files via peach + `:.; / output directory + "part" / file name stub + ); + +/ define empty schema for manifest +manifest:([] + file :`symbol$(); / path written + seq :`long$(); / sequence number within the partition + virtualcols :(); / list of virtual columns + syms :(); / list of instruments in the file + nsyms :`long$(); / count of instruments + rows :`long$(); / row count + mintime :`timestamp$(); / min time across the file (for pruning) + maxtime :`timestamp$(); / max time across the file + estbytes :`long$(); / estimated size at plan time + bytes :`long$(); / actual on-disk size + split :`boolean$(); / true if this file is a chunk of a split oversized instrument + status :`symbol$() / `ok | `error + ); + +checkandconvertcols:{[t] + / takes a table and checks whether any symbol or char columns exist in it + / if so, converts these to strings, as no Parquet datatype equivalent + :$[count c:exec c from meta[t] where t in "Ssc"; + ![t;();0b;c!{(string;x)} each c]; + t + ] + }; + +estimate:{[t;o;writeopt] + / for a partition of data, estimates the size of the tables to be saved to disk + / if calibrate flag is true in o, a test write is carried out + / returns a table of storage stats for all instruments and the compression ratio, which may have changed depending on calibration + / group by virtualcols as well as symcol, so a sym occurring under multiple virtualcols combinations gets its own row + gcols:distinct o[`virtualcols],o[`symcol]; + cnts:`rowcnt xasc 0!?[t;();gcols!gcols;enlist[`rowcnt]!enlist(count;o[`timecol])]; / select rowcnt:count time by gcols from t, using appropriate substitutions for time and sym/virtualcols + medsym:cnts @ first where abs[cnt-med[cnt]]=min[abs[cnt-med[cnt:cnts`rowcnt]]]; + mask:min each flip {[t;medsym;x] t[x]=medsym[x]}[t;medsym] each gcols; / row matches medsym's full (virtualcols,symcol) combination, not just its sym + bytesperrow:%[-22!t:.z.m.checkandconvertcols t[where mask];medsym`rowcnt]; + + / calibrate compression ratio if option is enabled + if[o`calibrate; + .z.m.loginfo[`pqx;"Calibrating compression ratio"]; + o[`compressionratio]:.z.m.calibrateratio[t;o;writeopt] + ]; + + / return stats and (new) compression ratio + :(update estbyt:rowcnt*bytesperrow*o[`compressionratio] from cnts;o[`compressionratio]) + }; + +calibrateratio:{[t;o;writeopt] + / writes a sample of data to disk and reads its size on disk + / calculates the compression ratio and returns if a new ratio was successfully calculated, otherwise old ratio is maintained + + / remove leading : from outdir + testloc:$[":" ~ first string[o`outdir]; + 1_string[o`outdir],"/testWrite.parquet"; + string[o`outdir],"/testWrite.parquet"]; + + / outputs two items - success flag and any error msg + .z.m.loginfo[`pqx;"Attempting test write of median sym for calibration"]; + res:.z.m.tryfn[`.m.di.0pqx.arrow.pq.writeParquetFromTable;(testloc;t;writeopt)]; + + / if error returned in first item of res, just return old compression ratio + if[not first res; + .z.m.logwarn[`pqx;"Calibration write unsuccessful. Error - ",last res]; + .z.m.logwarn[`pqx;"Returning existing compression ratio"]; + :o`compressionratio + ]; + + .z.m.loginfo[`pqx;"Test write successful"]; + + sizeondisk:hcount hsym `$testloc; + newratio:sizeondisk % -22!t; + + / clean test file + .z.m.loginfo[`pqx;"Cleaning up test file"]; + hdel hsym `$testloc; + + / return new ratio + .z.m.loginfo[`pqx;"Returning calibrated compression ratio"]; + :newratio + }; + +calcsize:{[tbl;symcol;syms;seqno] + / find the estimated size in bytes for file to be saved down by querying symstats + .z.m.loginfo[`pqx;"Getting estimated bytes for planned files"]; + :sum[?[tbl;enlist(in;symcol;enlist syms);0b;()]`estbyt] + }; + +plan:{[t;o;maxsize] + / planning function to bucket instruments based on next-fit packing + / if one sym per file, just output individual buckets for each sym + / else + / if an instrument can be added to a bucket without that bucket exceeding the target size, it will be added to that bucket + / else a new bucket is created + / large instruments are also split into multiple files if splitoversized flag is true + symstats:t; + + / virtualcols take precedence over everything else - one file per distinct combination. + / computed directly here (rather than through the shared bucket-then-recompute tail below) because + / symstats can carry multiple rows per sym once virtualcols grouping is active (see estimate), and the + / tail's calcsize call would double count a sym's bytes across its different virtualcols combinations + if[count o`virtualcols; + .z.m.loginfo[`pqx;"Enforcing one file per virtualcols combination"]; + grp:0!?[t;();(o`virtualcols)!o`virtualcols;`syms`estbytes!((o`symcol);(sum;`estbyt))]; + :update seqno:enlist each 1+til count grp, estbytes:enlist each "j"$estbytes from grp + ]; + + plans:(); + + / if one sym per file, enlist each sym to assign to individual buckets + / else move to oversized and packing logic + $[o`onesymperfile; + [.z.m.loginfo[`pqx;"Enforcing one sym per file"]; + plans,:enlist each t[o[`symcol]] + ]; + / if split oversized is required, check against maxsize and return a plan entry for each required file + [if[o`splitoversized; + .z.m.loginfo[`pqx;"Splitting large instruments"]; + t:update islargerthantargetsize:estbyt>maxsize from t; + oversized:select from t where islargerthantargetsize; + t:t except oversized; + oversized:update numfiles:ceiling[estbyt%maxsize] from oversized; + plans,:enlist each raze {[t;c] t[`numfiles]#enlist t[c]}[;o`symcol] each oversized + ]; + + / next fit function for packing instruments into buckets if they conform to the max size + if[count t; + .z.m.loginfo[`pqx;"Bucketing small instruments"]; + tabs:t[o[`symcol]]; + sizes:t`estbyt; + n:count tabs; + + step:{[maxsize;sizes;state;i] + sz:sizes i; + tot:state 1; + $[(tot+sz)>maxsize; (1+state 0; sz); (state 0; tot+sz)] + }[maxsize;sizes]; + bins: (step\[(0;0);til n])[;0]; + + plans,:value[tabs @ group bins] + ] + ] + ]; + plans:flip `seqno`syms!((1 + til count plans);plans); + + / attach estbytes by file to plan's output + :0!`syms xgroup update estbytes:"j"$.z.m.calcsize[symstats;o`symcol;;]'[syms;seqno]%count seqno by syms from plans + }; + +datalookup:{[t;symcol;syms;cnt;mask] + / get lists of indices by file + / a pass with multiple instruments is assumed to be one file only, hence the return is flattened into one list + / mask restricts to rows belonging to this file's virtualcols combination (all 1b when virtualcols is unset) + $[1count levels; + '"di.pqx: expected ",string[count levels]," partition level(s) ",.Q.s1[levels],", found ",string[depth]," on disk"]; + idx:lv+til count levels; + hivecols:distinct {[idx;x] `$first each "=" vs' x idx}[idx] each splits; + if[1type deps; + '"di.pqx: deps must be a dict with a `log key"]; + if[not `log in key deps; + '"di.pqx: log dependency is required; pass `info`warn`error functions keyed on `log"]; + if[99h<>type deps`log; + '"di.pqx: log value must be a dict of `info`warn`error functions"]; + if[not all (`info`warn`error) in key deps`log; + '"di.pqx: log dict must have `info`warn`error keys; got: ",(", " sv string key deps`log)]; + .z.m.loginfo:deps[`log]`info; + .z.m.logwarn:deps[`log]`warn; + .z.m.logerr:deps[`log]`error; + }; diff --git a/di/pqx/test.csv b/di/pqx/test.csv new file mode 100644 index 00000000..fcbb66ff --- /dev/null +++ b/di/pqx/test.csv @@ -0,0 +1,214 @@ +action,ms,bytes,lang,code,repeat,minver,comment +before,0,0,q,pqx:use`di.pqx,1,,Load module +before,0,0,q,logdep:`info`warn`error!(3#{[c;m] }),1,,No-op log dependency for tests +before,0,0,q,pqx.init[enlist[`log]!enlist logdep],1,,Wire the required log dependency + +before,0,0,q,pqxbasic:([]sym:`AAPL`MSFT`GOOG`AAPL`MSFT`GOOG`AAPL`MSFT`GOOG;time:2025.07.15D09:30:00.000000000+1000000000*til 9;price:100.0 200.0 300.0 101.0 201.0 301.0 102.0 202.0 302.0;size:10 20 30 40 50 60 70 80 90),1,,Deterministic multi-sym table for general checks +before,0,0,q,pqxunsorted:([]sym:`B`A`B`A`A;time:2025.07.15D00:00:00.000000005 2025.07.15D00:00:00.000000004 2025.07.15D00:00:00.000000003 2025.07.15D00:00:00.000000002 2025.07.15D00:00:00.000000001;price:1.0 2.0 3.0 4.0 5.0),1,,Two-sym table with times out of order for presort checks +before,0,0,q,pqxempty:0#pqxbasic,1,,Zero-row table for the empty-table edge case +before,0,0,q,pqxoversized:([]sym:20000#`AAPL;time:2025.07.15D00:00:00.000000000+til 20000;price:20000?100.0),1,,Single-instrument table sized to exceed a small target file size +before,0,0,q,pqxnotime:([]sym:5#`AAPL;price:1.0 2.0 3.0 4.0 5.0),1,,Table missing the time column +before,0,0,q,pqxaltsym:([]sym:`B`A`B`A`A;alt:`X`Y`X`Y`Y;time:2025.07.15D00:00:00.000000005 2025.07.15D00:00:00.000000004 2025.07.15D00:00:00.000000003 2025.07.15D00:00:00.000000002 2025.07.15D00:00:00.000000001;price:1.0 2.0 3.0 4.0 5.0),1,,Table with a second candidate instrument column to probe the symcol option +before,0,0,q,pqxsingle:([]sym:enlist`AAPL;time:enlist 2025.07.15D09:30:00.000000000;price:enlist 123.45),1,,Single-row table edge case +before,0,0,q,pqxdict:([]sym:`AAPL`MSFT`GOOG`AAPL`MSFT`GOOG`AAPL`MSFT`GOOG;exch:`N`N`N`O`O`O`N`N`N;time:2025.07.15D09:30:00.000000000+1000000000*til 9;price:100.0 200.0 300.0 101.0 201.0 301.0 102.0 202.0 302.0;size:10 20 30 40 50 60 70 80 90),1,,Table with an exchange column where every instrument trades on both exchanges - probes virtualcols row-filtering correctness + +true,0,0,q,98h~type pqx.getmanifest[],1,,Manifest is a table before anything is written + +fail,0,0,q,pqx.init[()],1,,init fails when deps is not a dict +fail,0,0,q,pqx.init[enlist[`nolog]!enlist logdep],1,,init fails when deps has no log key +fail,0,0,q,pqx.init[enlist[`log]!enlist 5],1,,init fails when the log value is not a dict +fail,0,0,q,pqx.init[enlist[`log]!enlist (enlist`info)!enlist {[c;m] }],1,,init fails when the log dict is missing required warn/error keys + +run,0,0,q,pqx.extract[pqxbasic;`pqxtrade;2025.07.15;enlist[`outdir]!enlist `:pqxout1/],1,,Extract a multi-sym table with default options +true,0,0,q,9~exec sum rows from pqx.getmanifest[] where file like "*pqxout1*",1,,All input rows are accounted for across written files +true,0,0,q,3~exec sum nsyms from pqx.getmanifest[] where file like "*pqxout1*",1,,All three instruments are accounted for +true,0,0,q,all `ok=exec status from pqx.getmanifest[] where file like "*pqxout1*",1,,Every written file reports ok status +true,0,0,q,all 00,1,,Sanity check - manifest already held rows from an earlier extract before this call +true,0,0,q,not pqxret1~pqx.getmanifest[],1,,extract's return value is scoped to this call, not the entire accumulated manifest +true,0,0,q,(count pqxret1)manifestcountbeforef6,1,,extract still appends to the accumulated in-memory manifest despite the sidecar write failure + +run,0,0,q,pqx.extract[pqxbasic;`pqxtrade;2025.07.29;`outdir`onesymperfile!(`:pqxout12/;1b)],1,,Extract a multi-sym table with onesymperfile on +true,0,0,q,3~count select from pqx.getmanifest[] where file like "*pqxout12*",1,,onesymperfile produces one file per instrument instead of bucketing them together +true,0,0,q,all 1=exec nsyms from pqx.getmanifest[] where file like "*pqxout12*",1,,Every file written under onesymperfile contains exactly one instrument +true,0,0,q,9~exec sum rows from pqx.getmanifest[] where file like "*pqxout12*",1,,All input rows are still accounted for across the per-instrument files +true,0,0,q,not any exec split from pqx.getmanifest[] where file like "*pqxout12*",1,,Files are not flagged as split when written one instrument per file + +run,0,0,q,pqx.extract[pqxoversized;`pqxtrade;2025.07.30;`targetsize`maxfactor`calibrate`compressionratio`outdir`splitoversized`onesymperfile!(1000;1.1;0b;0.30;`:pqxout13/;1b;1b)],1,,onesymperfile takes priority over splitoversized for an oversized single instrument +true,0,0,q,1~count select from pqx.getmanifest[] where file like "*pqxout13*",1,,onesymperfile forces splitoversized off, so the oversized instrument is written to a single file +true,0,0,q,20000~first exec rows from pqx.getmanifest[] where file like "*pqxout13*",1,,The single file still contains every row of the oversized instrument +true,0,0,q,not first exec split from pqx.getmanifest[] where file like "*pqxout13*",1,,The single file is not flagged as split, since splitting was suppressed by onesymperfile + +fail,0,0,q,pqx.extract[pqxbasic;`pqxtrade;2025.07.31;`outdir`badoption!(`:pqxoutf7/;1)],1,,Extract fails when passed an option key that isn't recognised + +run,0,0,q,pqx.extract[pqxdict;`pqxtrade;2025.08.01;`outdir`virtualcols!(`:pqxout14/;enlist`exch)],1,,Extract with virtualcols set to exch - one file per exchange combination +true,0,0,q,2~count select from pqx.getmanifest[] where file like "*pqxout14*",1,,virtualcols produces exactly one file per distinct exch value +true,0,0,q,9~exec sum rows from pqx.getmanifest[] where file like "*pqxout14*",1,,All input rows are accounted for exactly once across the virtualcols files - no row is duplicated across combinations +true,0,0,q,all 3=exec nsyms from pqx.getmanifest[] where file like "*pqxout14*",1,,Every instrument trades on both exchanges, so both files see all three instruments +true,0,0,q,"1~count select from pqx.getmanifest[] where (file like ""*pqxout14*""),(file like ""*exch=N*"")",1,,Output path is partitioned into a Hive-style exch=N subdirectory +true,0,0,q,"1~count select from pqx.getmanifest[] where (file like ""*pqxout14*""),(file like ""*exch=O*"")",1,,Output path is partitioned into a Hive-style exch=O subdirectory +true,0,0,q,"6~first exec rows from pqx.getmanifest[] where (file like ""*pqxout14*""),(file like ""*exch=N*"")",1,,The exch=N file contains only the 6 rows that actually belong to exch=N - not every row for its instruments +true,0,0,q,"3~first exec rows from pqx.getmanifest[] where (file like ""*pqxout14*""),(file like ""*exch=O*"")",1,,The exch=O file contains only the 3 rows that actually belong to exch=O - not every row for its instruments + +run,0,0,q,"pqxfile14n:1_string first exec file from pqx.getmanifest[] where (file like ""*pqxout14*""),(file like ""*exch=N*"")",1,,Resolve the path written for the exch=N virtualcols file +run,0,0,q,pqxraw14n:.m.di.0pqx.arrow.pq.readParquetToTable[pqxfile14n;(0#`)!()],1,,Read the raw on-disk data directly, bypassing readfile's reconstruction +true,0,0,q,not `exch in cols pqxraw14n,1,,The virtualcols column (exch) is stripped from the on-disk data rather than duplicated into every row +true,0,0,q,`sym in cols pqxraw14n,1,,Non-virtualcols columns are still written to disk as normal +run,0,0,q,pqxreconstructed14n:pqx.readfile[pqxfile14n;(0#`)!()],1,,Read the same file back through readfile, which reconstructs values encoded in the path +true,0,0,q,all pqxreconstructed14n[`exch]=`N,1,,readfile reconstructs the exch column as `N for every row, from the exch=N path segment +true,0,0,q,all pqxreconstructed14n[`date]=2025.08.01,1,,readfile also reconstructs the date partition segment as an actual date +true,0,0,q,(cols pqxraw14n)~cols[pqxreconstructed14n] except `date`exch,1,,readfile only adds the reconstructed columns - the rest of the schema is untouched + +run,0,0,q,pqx.extract[pqxdict;`pqxtrade;2025.08.02;`outdir`virtualcols`onesymperfile`splitoversized!(`:pqxout15/;enlist`exch;1b;1b)],1,,virtualcols takes priority over onesymperfile and splitoversized even when both are explicitly requested +true,0,0,q,2~count select from pqx.getmanifest[] where file like "*pqxout15*",1,,Still exactly one file per exch combination, not one per instrument +true,0,0,q,not any 1=exec nsyms from pqx.getmanifest[] where file like "*pqxout15*",1,,Files are not split down to one instrument each, confirming onesymperfile was overridden by virtualcols + +fail,0,0,q,pqx.extract[pqxdict;`pqxtrade;2025.08.03;`outdir`virtualcols!(`:pqxoutf8/;enlist`nosuchcol)],1,,Extract fails when a virtualcols column is not present in the table + +run,0,0,q,pqx.extract[pqxdict;`pqxtrade;2025.08.04;`outdir`virtualcols!(`:pqxout16/;`sym`exch)],1,,Extract with virtualcols including symcol itself - the same column drives row selection and path partitioning +true,0,0,q,6~count select from pqx.getmanifest[] where file like "*pqxout16*",1,,One file per distinct (sym,exch) combination +true,0,0,q,9~exec sum rows from pqx.getmanifest[] where file like "*pqxout16*",1,,All input rows are accounted for exactly once even when symcol overlaps virtualcols +run,0,0,q,"pqxfile16:1_string first exec file from pqx.getmanifest[] where (file like ""*pqxout16*""),(file like ""*sym=AAPL*""),(file like ""*exch=N*"")",1,,Resolve the path for the sym=AAPL/exch=N combination +run,0,0,q,pqxraw16:.m.di.0pqx.arrow.pq.readParquetToTable[pqxfile16;(0#`)!()],1,,Read the raw on-disk data for that combination +true,0,0,q,not `sym in cols pqxraw16,1,,symcol itself is stripped from the on-disk data when it is also a virtualcols column +true,0,0,q,2~count pqxraw16,1,,The sym=AAPL/exch=N file contains exactly its own 2 rows - not every AAPL or every exch=N row +run,0,0,q,pqxreconstructed16:pqx.readfile[pqxfile16;(0#`)!()],1,,Reconstruct the values stripped from the path +true,0,0,q,all pqxreconstructed16[`sym]=`AAPL,1,,readfile reconstructs sym from the path even though sym is also symcol +true,0,0,q,all pqxreconstructed16[`exch]=`N,1,,readfile reconstructs exch from the path alongside sym + +run,0,0,q,pqxvt1:pqx.buildvirtualtable[`:pqxout1/;`pqxtrade;`date;`symbol$()],1,,Build a virtual table over a single-file no-virtualcols partition +run,0,0,q,pqxvt12:pqx.buildvirtualtable[`:pqxout12/;`pqxtrade;`date;`symbol$()],1,,Build a virtual table over a multi-file no-virtualcols partition (onesymperfile) +run,0,0,q,pqxvt14:pqx.buildvirtualtable[`:pqxout14/;`pqxtrade;`date;enlist`exch],1,,Build a virtual table over a single virtualcols level +run,0,0,q,pqxvt16:pqx.buildvirtualtable[`:pqxout16/;`pqxtrade;`date;`sym`exch],1,,Build a virtual table over two virtualcols levels, one overlapping symcol + +true,0,0,q,`buildvirtualtable in key pqx,1,,buildvirtualtable is exported from the module +true,0,0,q,`castvirtualcol in key pqx,1,,castvirtualcol is exported from the module +true,0,0,q,`checkvirtuallevels in key pqx,1,,checkvirtuallevels is exported from the module + +true,0,0,q,9~exec count i from select from pqxvt1,1,,Virtual table over a single-file partition reports every row +true,0,0,q,-14h~type first exec date from select date from pqxvt1,1,,The reconstructed date column carries the real kdb+ date type, not a string +true,0,0,q,enlist[2025.07.15]~exec distinct date from select date from pqxvt1,1,,The reconstructed date matches the extract's own partition date +true,0,0,q,not `exch in exec c from meta pqxvt1,1,,No virtualcols column appears when the extract had none + +true,0,0,q,9~exec count i from select from pqxvt12,1,,Virtual table sums rows correctly across multiple files written at the same partition level +true,0,0,q,3 3 3~asc value exec count i by sym from select sym from pqxvt12,1,,Every instrument's file is discovered and its rows counted, even though sym stays a real on-disk column here + +true,0,0,q,9~exec count i from select from pqxvt14,1,,Virtual table over virtualcols-partitioned files reports every row across both files +true,0,0,q,-11h~type first exec exch from select exch from pqxvt14,1,,The reconstructed virtualcols column carries the symbol type +true,0,0,q,6~exec count i from select from pqxvt14 where exch=`N,1,,Filtering on the reconstructed virtualcols column prunes to just the exch=N file's rows +true,0,0,q,3~exec count i from select from pqxvt14 where exch=`O,1,,Filtering on the reconstructed virtualcols column prunes to just the exch=O file's rows +true,0,0,q,`N`O~asc exec distinct exch from select exch from pqxvt14,1,,Both virtualcols combinations are discovered from their subdirectories + +true,0,0,q,9~exec count i from select from pqxvt16,1,,Virtual table over two virtualcols levels still reports every row across all six files +true,0,0,q,"2~exec count i from select from pqxvt16 where sym=`AAPL,exch=`N",1,,Filtering on both reconstructed virtualcols columns together prunes to the matching combination's file +true,0,0,q,-11h~type first exec sym from select sym from pqxvt16,1,,sym is reconstructed as a real symbol on the virtual table even though it was stripped from the on-disk data as a virtualcols column +true,0,0,q,all `sym`exch in exec c from meta pqxvt16,1,,Both virtualcols columns are present as virtual columns even though one shares its name with symcol + +true,0,0,q,2025.08.01~first pqx.castvirtualcol[`date;0;enlist ("date=2025.08.01";"exch=N";"f.parquet")],1,,castvirtualcol reconstructs the datecol level of a path segment as a date +true,0,0,q,`N~first pqx.castvirtualcol[`date;1;enlist ("date=2025.08.01";"exch=N";"f.parquet")],1,,castvirtualcol reconstructs a non-datecol level of a path segment as a symbol +true,0,0,q,2025.01.01~first pqx.castvirtualcol[`asOf;0;enlist enlist "asOf=2025.01.01"],1,,castvirtualcol works for any datecol name, not just a column literally named `date +true,0,0,q,2025.08.01 2025.08.02~pqx.castvirtualcol[`date;0;(enlist "date=2025.08.01";enlist "date=2025.08.02")],1,,castvirtualcol casts every row's segment when given multiple rows at once + +fail,0,0,q,pqx.buildvirtualtable[`:pqxout14/;`pqxtrade;`date;`symbol$()],1,,buildvirtualtable fails when a declared datecol/virtualcols combination omits an exch level that really exists on disk +fail,0,0,q,pqx.buildvirtualtable[`:pqxout16/;`pqxtrade;`date;enlist`sym],1,,buildvirtualtable fails when virtualcols omits one of two levels that really exist on disk +fail,0,0,q,pqx.buildvirtualtable[`:pqxout14/;`pqxtrade;`date;enlist`wrongname],1,,buildvirtualtable fails when a virtualcols name doesn't match the on-disk key at that level, even though the level count is right +fail,0,0,q,pqx.buildvirtualtable[`:pqxout16/;`pqxtrade;`date;`exch`sym],1,,buildvirtualtable fails when virtualcols are given in the wrong order relative to the on-disk levels + +run,0,0,q,pqx.checkvirtuallevels[`date`exch;0;enlist ("date=2025.08.01";"exch=N";"f.parquet")],1,,checkvirtuallevels succeeds when datecol/virtualcols match the on-disk levels in count and name +fail,0,0,q,pqx.checkvirtuallevels[enlist`date;0;enlist ("date=2025.08.01";"exch=N";"f.parquet")],1,,checkvirtuallevels fails when fewer levels are declared than are actually present on disk +fail,0,0,q,pqx.checkvirtuallevels[`date`wrongname;0;enlist ("date=2025.08.01";"exch=N";"f.parquet")],1,,checkvirtuallevels fails when a declared name doesn't match the on-disk key at that position +fail,0,0,q,pqx.checkvirtuallevels[`date`exch;0;(("date=2025.08.01";"exch=N";"f.parquet");("date=2025.08.02";"f2.parquet"))],1,,checkvirtuallevels fails when files under the same path disagree on partition depth + +run,0,0,q,system "mkdir -p pqxoutf9/pqxempty/date=2025.08.05",1,,Create a table directory containing no parquet files +run,0,0,q,pqxvtempty:pqx.buildvirtualtable[`:pqxoutf9/;`pqxempty;`date;`symbol$()],1,,Building a virtual table succeeds even when no parquet files are found under the directory +fail,0,0,q,select from pqxvtempty,1,,Querying a virtual table built over zero files fails rather than silently returning an empty result + +after,0,0,q,system "rm -rf pqxout1 pqxout1b pqxout2 pqxout3 pqxout4 pqxout5 pqxout6 pqxout7 pqxout8 pqxout9 pqxout10 pqxout11 pqxout12 pqxout13 pqxout14 pqxout15 pqxout16 pqxout17 pqxoutf1 pqxoutf1b pqxoutf2 pqxoutf3 pqxoutf4 pqxoutf5 pqxoutf6 pqxoutf7 pqxoutf8 pqxoutf9",1,,Remove parquet output directories written by tests