Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion .github/workflows/function-comparison.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,18 @@
name: Function Comparison

on:
pull_request:
push:
branches:
- main
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
compare:
name: Compare Functions
Expand All @@ -31,4 +39,57 @@ jobs:
with:
python-version: '3.x'
- name: Run function comparison
run: python scripts/compare_functions.py
id: compare
# explicit shell: 'bash' in GitHub Actions means
# `bash --noprofile --norc -eo pipefail`. `set +e` disables errexit
# so the script's exit code can be captured from PIPESTATUS (pipefail
# + `tee` would otherwise mask it) and exposed as a step output.
# Exit codes: 0 = in sync, 1 = mismatch, 2 = operational error.
shell: bash
run: |
set +e
python scripts/compare_functions.py | tee comparison.log
code=${PIPESTATUS[0]}
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
exit "$code"
- name: Create issue on scheduled mismatch
# Only a confirmed mismatch (exit code 1) files an issue. Operational
# failures (exit code 2, e.g. unable to fetch Prometheus functions.go)
# still fail the job but must not create an "out of sync" issue that
# would then suppress a later genuine mismatch report via dedup.
# failure() overrides the implicit success() check after a mismatch.
if: failure() && github.event_name == 'schedule' && steps.compare.outputs.exit_code == '1'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ISSUE_TITLE="Function comparison failed: Rust functions out of sync with Prometheus"
EXISTING=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--search "in:title \"${ISSUE_TITLE}\"" \
--json number \
--jq 'length')
if [ "$EXISTING" -gt 0 ]; then
echo "An open issue already exists, skipping creation."
exit 0
fi
{
echo "The scheduled function comparison detected mismatches."
echo ""
echo "Rust functions in \`src/parser/function.rs\` are out of sync with"
echo "[Prometheus functions.go](https://github.com/prometheus/prometheus/blob/main/promql/parser/functions.go)."
echo ""
echo "See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details."
echo ""
echo "<details><summary>Comparison output</summary>"
echo ""
echo '```'
cat comparison.log
echo '```'
echo ""
echo "</details>"
} > issue_body.md
gh issue create \
--repo "${{ github.repository }}" \
--title "$ISSUE_TITLE" \
--body-file issue_body.md
16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,28 @@ name = "promql-parser"
readme = "README.md"
description = "Parse PromQL query into AST"
repository = "https://github.com/GreptimeTeam/promql-parser"
version = "0.10.0"
version = "0.11.1"
edition = "2021"
authors = ["The GreptimeDB Project Developers"]
keywords = ["prometheus", "promql", "parser"]
license = "Apache-2.0"
categories = ["parser-implementations"]

[dependencies]
cfgrammar = "0.13.5"
cfgrammar = "0.14.2"
chrono = "0.4.38"
lazy_static = "1.4.0"
lrlex = "0.13.5"
lrpar = "0.13.5"
lrlex = "0.14.2"
lrpar = "0.14.2"
regex = "1"
serde = { version = "1", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }

[features]
default = []
ser = ["serde", "serde_json"]

[build-dependencies]
cfgrammar = "0.13.5"
lrlex = "0.13.5"
lrpar = "0.13.5"
cfgrammar = "0.14.2"
lrlex = "0.14.2"
lrpar = "0.14.2"
4 changes: 2 additions & 2 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

use cfgrammar::yacc::YaccKind;
use lrlex::{ct_token_map, DefaultLexerTypes};
use lrlex::{CTTokenMapBuilder, DefaultLexerTypes};
use lrpar::CTParserBuilder;

fn main() -> Result<(), Box<dyn std::error::Error>> {
Expand All @@ -22,5 +22,5 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.recoverer(lrpar::RecoveryKind::None)
.grammar_in_src_dir("parser/promql.y")?
.build()?;
ct_token_map::<u16>("token_map", ctp.token_map(), None)
CTTokenMapBuilder::<u16>::new("token_map", ctp.token_map()).build()
}
18 changes: 15 additions & 3 deletions scripts/compare_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
"""
Script to compare Prometheus Go functions.go with Rust functions.rs
Ensures Rust functions are complete and consistent with Go version.

Exit codes:
0 - success, functions are in sync
1 - mismatch detected (Rust functions out of sync with Prometheus)
2 - operational error (e.g. network failure, file read error)
"""

import re
import sys
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass

# Exit code for a confirmed mismatch between Go and Rust functions.
EXIT_MISMATCH = 1
# Exit code for operational errors that are NOT mismatches (fetch/read
# failures, unexpected exceptions). The CI workflow distinguishes these so
# it only files an "out of sync" issue for a real mismatch.
EXIT_ERROR = 2


@dataclass
class FunctionDef:
Expand Down Expand Up @@ -185,7 +197,7 @@ def main():
go_content = result.stdout
except Exception as e:
print(f"Error fetching Go file: {e}")
sys.exit(1)
sys.exit(EXIT_ERROR)

# Read Rust functions.rs
rust_file = "src/parser/function.rs"
Expand All @@ -196,7 +208,7 @@ def main():
rust_content = f.read()
except Exception as e:
print(f"Error reading Rust file: {e}")
sys.exit(1)
sys.exit(EXIT_ERROR)

# Parse both files
go_functions = parse_go_functions(go_content)
Expand Down Expand Up @@ -270,7 +282,7 @@ def main():
sys.exit(0)
else:
print("\n❌ Issues found - please review and fix")
sys.exit(1)
sys.exit(EXIT_MISMATCH)


if __name__ == "__main__":
Expand Down
153 changes: 151 additions & 2 deletions src/parser/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,62 @@

use std::collections::HashMap;
use std::fmt;
use std::sync::RwLock;

use lazy_static::lazy_static;

use crate::parser::value::ValueType;
use crate::parser::{Expr, Prettier};
use crate::util::join_vector;

lazy_static! {
static ref EXTRA_FUNCTIONS: RwLock<HashMap<String, Function>> = RwLock::new(HashMap::new());
}

/// Register additional custom functions that the parser will recognize.
///
/// This allows extending the parser with functions not in upstream Prometheus,
/// similar to Thanos's XFunctions pattern. Can be called multiple times;
/// functions are accumulated additively.
///
/// Returns an error if any function name conflicts with a built-in Prometheus
/// function. Overriding built-in functions is not allowed.
///
/// # Example
///
/// ```
/// use promql_parser::parser::function::{register_extra_functions, Function};
/// use promql_parser::parser::value::ValueType;
///
/// register_extra_functions(vec![
/// Function::new("xrate", vec![ValueType::Matrix], 0, ValueType::Vector, true),
/// ]).unwrap();
///
/// // Now parse() will accept xrate as a valid function
/// let result = promql_parser::parser::parse("xrate(foo[5m])");
/// assert!(result.is_ok());
/// ```
pub fn register_extra_functions(funcs: Vec<Function>) -> Result<(), String> {
let mut map = EXTRA_FUNCTIONS.write().unwrap();
for f in &funcs {
if FUNCTIONS.contains_key(f.name) {
return Err(format!(
"cannot register custom function \"{}\": conflicts with built-in function",
f.name
));
}
}
for f in funcs {
map.insert(f.name.to_string(), f);
}
Ok(())
}

/// Clear all previously registered custom functions.
pub fn clear_extra_functions() {
EXTRA_FUNCTIONS.write().unwrap().clear();
}

/// called by func in Call
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "ser", derive(serde::Serialize))]
Expand Down Expand Up @@ -278,7 +327,7 @@ lazy_static! {
vec![ValueType::Matrix],
0,
ValueType::Vector,
true
false
),
function!(
"floor",
Expand Down Expand Up @@ -377,6 +426,13 @@ lazy_static! {
ValueType::Vector,
false
),
function!(
"integral",
vec![ValueType::Matrix, ValueType::Scalar],
1,
ValueType::Vector,
true
),
function!(
"irate",
vec![ValueType::Matrix],
Expand Down Expand Up @@ -550,6 +606,13 @@ lazy_static! {
),
function!("sgn", vec![ValueType::Vector], 0, ValueType::Vector, false),
function!("start", vec![], 0, ValueType::Scalar, true),
function!(
"start_timestamp",
vec![ValueType::Vector],
0,
ValueType::Vector,
true
),
function!("step", vec![], 0, ValueType::Scalar, true),
function!("sin", vec![ValueType::Vector], 0, ValueType::Vector, false),
function!("sinh", vec![ValueType::Vector], 0, ValueType::Vector, false),
Expand Down Expand Up @@ -619,8 +682,12 @@ lazy_static! {
}

/// get_function returns a predefined Function object for the given name.
/// It checks built-in functions first, then any registered custom functions.
pub(crate) fn get_function(name: &str) -> Option<Function> {
FUNCTIONS.get(name).cloned()
FUNCTIONS
.get(name)
.cloned()
.or_else(|| EXTRA_FUNCTIONS.read().unwrap().get(name).cloned())
}

#[cfg(test)]
Expand Down Expand Up @@ -692,6 +759,18 @@ mod tests {
let rate = get_function("rate").unwrap();
assert_eq!(rate.variadic, 0);
assert!(!rate.experimental);

// integral was added upstream after v3.8; it is experimental and takes
// an optional scalar "strategy" argument (0=left, 1=right, 2=trapezoid).
let integral = get_function("integral").unwrap();
assert_eq!(
integral.arg_types,
vec![ValueType::Matrix, ValueType::Scalar]
);
assert_eq!(integral.variadic, 1);
assert_eq!(integral.return_type, ValueType::Vector);
assert!(integral.experimental);

for func_name in ["max_of", "min_of"] {
let func = get_function(func_name).unwrap();
assert_eq!(func.arg_types, vec![ValueType::Scalar, ValueType::Scalar]);
Expand All @@ -708,4 +787,74 @@ mod tests {
assert!(!func.experimental);
}
}

#[test]
fn test_register_custom_function() {
// Register a custom function
register_extra_functions(vec![Function::new(
"xrate",
vec![ValueType::Matrix],
0,
ValueType::Vector,
true,
)])
.unwrap();

// Should be found by get_function
let func = get_function("xrate");
assert!(func.is_some());
let func = func.unwrap();
assert_eq!(func.name, "xrate");
assert_eq!(func.arg_types, vec![ValueType::Matrix]);
assert_eq!(func.variadic, 0);
assert_eq!(func.return_type, ValueType::Vector);
assert!(func.experimental);

// Built-in functions still work
assert!(get_function("rate").is_some());
assert!(get_function("sum_over_time").is_some());

// Unknown functions still return None
assert!(get_function("totally_nonexistent_xyz").is_none());
}

#[test]
fn test_register_multiple_custom_functions() {
register_extra_functions(vec![
Function::new(
"xdelta",
vec![ValueType::Matrix],
0,
ValueType::Vector,
true,
),
Function::new(
"xincrease",
vec![ValueType::Matrix],
0,
ValueType::Vector,
true,
),
])
.unwrap();

assert!(get_function("xdelta").is_some());
assert!(get_function("xincrease").is_some());
}

#[test]
fn test_register_builtin_override_rejected() {
// Attempting to override a built-in function should return an error
let result = register_extra_functions(vec![Function::new(
"rate",
vec![ValueType::Matrix],
0,
ValueType::Vector,
true,
)]);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("conflicts with built-in function"));
}
}
Loading
Loading