pco_store 
This crate uses pco to get the best possible compression ratio for numeric data, extending it with an easy to use API so you don't have to convert between row and columnar data structures yourself.
Postgres is currently required, though contributions are welcome to support other storage models.
To see the generated code, look in tests/expand or run cargo expand --test tests.
Supported data types
- pco supports
u16,u32,u64,i16,i32,i64,f16,f32,f64 - pco_store additionally supports
SystemTime,bool
Performance
Numeric compression algorithms take advantage of the mathematic relationships between a series of numbers to compress them to a higher degree than binary compression is able to. Of the numeric compression algorithms available in Rust, pco achieves both the best compression ratio and the best round-trip write and read time.
Compared to native Postgres arrays using binary compression, pco_store improves the compression ratio by 2x and improves read and write time by 5x in the included benchmarks. Better compression ratios can be expected with larger datasets.
Usage
The pco_store::store procedural macro accepts these arguments:
timestampaccepts the field name for a timestamp in the struct. Timestamps are internally stored as ani64microsecond offset from the Unix epoch. This addsstart_atandend_attimestamp columns to the resulting table. A composite index should coverstart_atandend_at.group_byaccepts one or more field names that are stored as uncompressed fields on the Postgres table that all other fields are grouped by. The fields are added asloadfilters, andstoreautomatically groups the input data by them. A composite index should cover these fields.float_roundsets the number of fractional decimal points to retain for float values. This helps improve the compression ratio when you don't need the full precision of the source data. Internally this stores the values asi64, with the fractional precision retained by multiplying by 10N at write time, and then at read time casting to float and dividing by 10N. Users should confirm that the generated integer values won't overflow pasti64::MAX.table_nameoverrides the Postgres table name. By default it underscores and pluralizes the struct name, soQueryStatbecomesquery_stats.
Additional notes:
- Each group should contain ten thousand or more rows. If your data is collected in smaller buckets than that in real-time, you may want a background job that routinely deletes and re-inserts the data into a smaller number of rows to improve the compression ratio.
- As a tradeoff for the improved compression ratio and read/write time, any additional read-time filtering must be done in Rust instead of SQL. When evaluating this data model, you will want to compare the relative performance of this code in production versus the SQL queries it replaces.
Example
With a Rust struct that groups timeseries stats into a single row per database_id, and only retains two fractional digits for float values:
use ;
And a matching Postgres table:
(
database_id bigint NOT NULL,
start_at timestamptz NOT NULL,
end_at timestamptz NOT NULL,
collected_at bytea STORAGE EXTERNAL NOT NULL,
fingerprint bytea STORAGE EXTERNAL NOT NULL,
calls bytea STORAGE EXTERNAL NOT NULL,
total_time bytea STORAGE EXTERNAL NOT NULL
);
(database_id);
(end_at, start_at);
STORAGE EXTERNAL is set so that Postgres doesn't try to compress the already-compressed fields
This uses a (end_at, start_at) index because it's more selective than (start_at, end_at) for common use cases. For example when loading the last week of stats, the end_at filter is what's doing the work to filter out rows.
end_at >= now - interval '7 days' AND start_at <= now
The stats can be written with store, read with load + decompress, and deleted with delete:
async
use FromStr;
pub static DB_POOL: LazyLock = new;
pub use transaction;
Additional examples can be found in tests/tests.rs.
Contributions are welcome to
- support decompression of only the fields requested at runtime
- support other storage models (filesystem, S3, etc)
- support compression for other data types (text, enums, etc)
- add a stream/generator API to avoid allocating Vecs when loading data
- add
copy_insupport to deadpool_postgres and tokio_postgresGenericClient