Skip to content

[AURON #1863] Support native Flink UNIX_TIMESTAMP: native scalar function - #2409

Open
weiqingy wants to merge 4 commits into
apache:masterfrom
weiqingy:AURON-1863-native
Open

[AURON #1863] Support native Flink UNIX_TIMESTAMP: native scalar function#2409
weiqingy wants to merge 4 commits into
apache:masterfrom
weiqingy:AURON-1863-native

Conversation

@weiqingy

@weiqingy weiqingy commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #1863. The converter that emits this function follows in a separate PR, once this one merges.

To show where this is going, the converter change is previewable here: weiqingy#1 (a draft in my fork, diffed against this PR's branch so it shows only the converter side). That PR will be opened against this repo after this one lands.

Rationale for this change

Flink's UNIX_TIMESTAMP converts a formatted date-time string to a Unix timestamp in seconds. AIP-1 lists it as a Phase 1 built-in function. Supporting it natively is the first step; this PR adds the native (Rust) evaluation, and a follow-up PR wires the Flink converter to emit it.

The hard part is matching Flink's parsing exactly. Flink parses with java.text.SimpleDateFormat in its default lenient mode, so it accepts non-zero-padded fields, field rollover, and trailing input. A strict parser diverges from Flink on the default format yyyy-MM-dd HH:mm:ss as soon as a field is not zero-padded, and would return a wrong timestamp rather than an error. So this implements a lenient parser rather than delegating to a strict one.

What changes are included in this PR?

A new Flink_UnixTimestamp ext scalar function in datafusion-ext-functions, registered through the existing ext-function path (no proto change).

It takes three string arguments [value, format, zoneId] and returns an Int64. The format is a translated strftime-style pattern and the zone is resolved by the caller; the converter PR supplies both.

The parser reproduces SimpleDateFormat lenient semantics for the supported fields: variable field widths, rollover normalization, tolerant of trailing input, a leading minus on numeric fields. It also handles the Julian/Gregorian hybrid calendar (cutover 1582-10-15), since GregorianCalendar is a hybrid while chrono is proleptic Gregorian, and resolves DST-ambiguous or nonexistent local times using the zone's standard offset, matching Flink.

Failure semantics match Flink: an unparseable string yields Long.MIN_VALUE, a NULL input yields NULL, and the milliseconds-to-seconds step truncates toward zero.

The function is registered but not yet emitted by any planner, so this PR is self-contained and dormant until the converter lands.

Are there any user-facing changes?

No. The function is not reachable from SQL until the converter PR is merged.

How was this patch tested?

18 unit tests derived from a differential oracle that was validated against real java.text.SimpleDateFormat across a large randomized set of (input, format, timezone) inputs. Coverage includes the happy path, field rollover, non-padded fields, trailing garbage, NULL, unparseable-to-Long.MIN_VALUE, DST gap and overlap across several zones, pre-1970 negative timestamps, pre-1582 hybrid-calendar dates, the arity guard, and canonical field widths.

…r function

Add the native Rust half of Flink's UNIX_TIMESTAMP: a scalar ext function
that parses a formatted date-time string to a Unix timestamp in seconds,
matching Flink's java.text.SimpleDateFormat lenient semantics.

The parser reimplements lenient field parsing (rollover, non-padded fields,
trailing input tolerance), the Julian/Gregorian hybrid calendar (cutover
1582-10-15), and DST resolution via the zone's standard offset for ambiguous
or nonexistent local times. Unparseable input yields Long.MIN_VALUE; NULL
input yields NULL; arity is validated.

The function is registered but unreferenced until the Flink converter that
emits it is added, so this change is self-contained.
Copilot AI review requested due to automatic review settings July 21, 2026 04:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a native (Rust) implementation of Flink’s UNIX_TIMESTAMP as an ext scalar function in the DataFusion extension layer, including a lenient parser intended to match java.text.SimpleDateFormat behavior, and wires it into the existing ext-function registry (dormant until planner/converter support lands).

Changes:

  • Register new ext scalar function name Flink_UnixTimestamp in the shared ext-function factory.
  • Implement a lenient, SimpleDateFormat-like date/time string parser with timezone/DST and hybrid Julian/Gregorian calendar handling.
  • Add unit tests covering parsing leniency, rollover, DST gap/overlap, failure semantics (i64::MIN), and NULL propagation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
native-engine/datafusion-ext-functions/src/lib.rs Registers Flink_UnixTimestamp in the ext-function factory.
native-engine/datafusion-ext-functions/src/flink_datetime.rs Implements the native Flink UNIX_TIMESTAMP evaluator and its unit tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}
"Spark_IsNaN" => Arc::new(spark_isnan::spark_isnan),
"Flink_UnixTimestamp" => Arc::new(flink_datetime::flink_unix_timestamp),
_ => df_unimplemented_err!("spark ext function not implemented: {name}")?,
Comment on lines +30 to +32
/// Flink `UNIX_TIMESTAMP(value, format)`: parse a formatted date-time string to
/// a Unix timestamp in seconds, replicating `java.text.SimpleDateFormat`
/// lenient semantics.
@weiqingy

Copy link
Copy Markdown
Contributor Author

Hi @Tartarus0zm, could you please help review this PR when you get a chance? Thanks!

… native doc

The registry now dispatches Flink functions in addition to Spark, so the
not-implemented fallthrough message no longer says spark. Also clarify that
the doc comment's UNIX_TIMESTAMP(value, format) refers to the Flink SQL
function, distinct from the native arity-3 [value, chronoFormat, zoneId]
contract described just below it.
Copilot AI review requested due to automatic review settings July 21, 2026 19:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment on lines +334 to +337
let naive: NaiveDateTime = match DateTime::from_timestamp(local_sec, 0) {
Some(dt) => dt.naive_utc(),
None => return 0,
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This compiles as-is with chrono 0.4.45. DateTime::from_timestamp has been an associated function on DateTime<Utc> since chrono 0.4.31, so DateTime::from_timestamp(local_sec, 0) resolves to Option<DateTime<Utc>> here. The native library is built in CI (the flink job builds native, no skip flag) and is green, so a compile error on this line would have failed the build.

@weiqingy

weiqingy commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Both Copilot comments were addressed in ccc3509. Generalized the not-implemented fallthrough message from "spark ext function" to "auron ext function" now that the registry serves Flink too, and clarified the doc comment so the leading UNIX_TIMESTAMP(value, format) reads as the Flink SQL form, distinct from the arity-3 native contract described just below it.

@weiqingy

Copy link
Copy Markdown
Contributor Author

The only failing check is triage, and it is a repo-wide labeler issue unrelated to this PR. #2410 bumped actions/labeler to v7, whose stricter js-yaml rejects the current .github/labeler.yml (deficient indentation (27:7)). This PR changes only native-engine/ files. The fix is in #2414 (issue #2413); once it merges, triage parses cleanly on the next run. All build and test checks here pass.

@weiqingy weiqingy closed this Jul 22, 2026
@weiqingy weiqingy reopened this Jul 22, 2026
@Tartarus0zm
Tartarus0zm self-requested a review July 22, 2026 13:51
@Tartarus0zm

Copy link
Copy Markdown
Contributor

hi @weiqingy the commit message should start with [AURON #1863], this PR is not linked to #1863 .

@weiqingy

Copy link
Copy Markdown
Contributor Author

@Tartarus0zm Thanks for the review. Both commits already start with [AURON #1863], but the PR body previously said Part of #1863, which only mentioned the issue without creating a formal link. I’ve changed it to Closes #1863, so the PR now appears as a linked PR under the issue.

One thing to note: this PR only covers the native Rust implementation. The Flink converter that emits the function will come in a separate PR, and the zero-argument form is still pending. As a result, #1863 will not be fully complete when this PR merges.

Would you prefer that we keep #1863 open after the merge to track the converter and zero-argument follow-ups, or should I create a dedicated sub-issue for the remaining work? Either approach works for me.

/// timezone problems are hard errors rather than silent defaults, so a plumbing
/// bug cannot surface as silently wrong data.
pub fn flink_unix_timestamp(args: &[ColumnarValue]) -> Result<ColumnarValue> {
if args.len() != 3 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://nightlies.apache.org/flink/flink-docs-release-1.20/docs/dev/table/functions/systemfunctions/
In Flink, UNIX_TIMESTAMP has three usage forms: zero-argument, one-argument, and two-argument. Here I see the validation requires exactly three parameters, so is the idea to align all three Flink usage forms to a single Rust implementation via the Java translation layer(Infer from this commit)? However, I don't see any handling for the zero-argument case, which should return the current system time. Could you describe your thought process? It would help to elaborate more in the comments as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the intent. The arity-3 [value, chronoFormat, zoneId] shape is a native contract, not a user-facing signature, and the Java side normalizes Flink's two string-parsing forms onto it:

  • 1-arg UNIX_TIMESTAMP(str): the converter supplies Flink's default yyyy-MM-dd HH:mm:ss.
  • 2-arg UNIX_TIMESTAMP(str, fmt): fmt has to be a literal, and it gets translated from the Java pattern to the %-specifier form the Rust parser reads. Non-literal or untranslatable patterns are rejected in isSupported, and the whole Calc runs on Flink.
  • The session time zone is resolved at plan time from table.local-time-zone and passed as the third argument.

So by the time a call reaches the Rust function all three arguments are bound. Any other arity means the plan was built wrong, not that the user wrote something unusual, which is why it's a hard error rather than a default. And yes, weiqingy#1 is that converter. It opens here once this one merges.

On the 0-arg form: it's deliberately not covered, and the converter returns false for arity 0 so those queries fall back to Flink (testUnixTimestampZeroArgFallsBack pins that). Two reasons it needs its own design rather than another branch in this function:

  1. It's a different function. In 1.18.1 the planner registers the niladic form to DateTimeUtils.unixTimestamp(), which is System.currentTimeMillis() / 1000, and FlinkSqlOperatorTable.UNIX_TIMESTAMP is built with .notDeterministic(). So it's re-evaluated per record and never folded to a literal at plan time. It parses nothing.
  2. The ext-function path can't express it today. create_auron_ext_function returns a ScalarFunctionImplementation, and SimpleScalarUDF::invoke_with_args forwards only args.args, dropping number_rows. A 0-arg call would arrive with an empty slice and no way to size its output array, while ScalarFunctionExpr::evaluate errors when the returned array length doesn't match the batch row count. Supporting it means either carrying the row count in an argument or using a dedicated expression node, plus a decision on whether the clock is read once per batch or once per row, which is observable.

I kept #1863 open to track it, with that reasoning in this comment.

Good call on the comment. I've expanded the doc block to say the arity-3 shape is the normalized form of the two string arities, and that the no-argument form isn't routed here. If you'd rather see the 0-arg form land before this merges and it makes the review easier for you, I can pick it up.

…uded 0-arg form

The native function takes [value, chronoFormat, zoneId] and rejects any
other arity. Record in the doc block why: arity 3 is the normalized form
of Flink's one- and two-argument string-parsing calls, with the default
format, the translated pattern and the session time zone all bound on the
JVM side, so a call arriving here with a different shape is a plumbing
bug rather than user input.

Also state that Flink's no-argument UNIX_TIMESTAMP() is not routed here:
it parses nothing, yields per-record wall-clock time, and has no input
column to size the output against.
Copilot AI review requested due to automatic review settings July 27, 2026 22:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment on lines +136 to +139
fn parse_format(format: &str) -> Result<Vec<Token>> {
let bytes = format.as_bytes();
let mut tokens = Vec::new();
let mut i = 0;

@weiqingy weiqingy Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7cd8c8cf, though as a per-row Long.MIN_VALUE rather than a hard error.

It's reachable, which makes it worse than a doc-contract inconsistency. FlinkDateTimeFormatConverter.scan("") returns an empty token list rather than null, and the adjacency check passes vacuously, so translate("") returns Optional.of(""). A user writing UNIX_TIMESTAMP(ts, '') gets the native path with an empty format and silently reads back 1970-01-01.

On rejecting it up front: I went the other way, because Flink doesn't fail the query there. Running Flink's own code:

DateTimeUtils.unixTimestamp("2020-10-10 00:00:01", "", tz) = -9223372036854775808
DateTimeUtils.unixTimestamp("", "", tz)                    = -9223372036854775808

It returns Long.MIN_VALUE for all 30 (input, timezone) pairs I checked. So a hard error would abort a query that runs fine on Flink, trading a wrong value for a crash. The "format problems are hard errors" line rests on the format being a converter-supplied constant, where a bad one is a plumbing bug. An empty format isn't a plumbing bug, it's legal SQL, so that contract doesn't cover this case.

Your SimpleDateFormat reasoning is what pointed at the right fix, so I anchored the guard on it. "Fails if it consumes 0 chars" is DateFormat.parse's pos.index == 0 rule, so the condition is a zero-consumption parse rather than an empty format string. Those coincide here: a literal consumes one byte and a field needs at least one digit, so an empty token list is the only way to finish having consumed nothing. Keeping it phrased that way means the guard doesn't over-reject the neighbouring case. A format of %% against input % still yields 0, matching unixTimestamp("%", "%", UTC) = 0.

Covered by empty_format_never_matches, which I confirmed fails without the guard (returns 0 instead of MIN).

… format

An empty format tokenizes to an empty token list, so the parse loop never
ran and every input matched the epoch defaults, yielding 1970-01-01
instead of a parse failure. Flink returns Long.MIN_VALUE there, because
DateFormat.parse throws whenever parsing advances the position by zero
characters, so the divergence was a silent wrong answer on a reachable
plan: UNIX_TIMESTAMP(ts, '') translates to an empty native format.

Treat a zero-consumption parse as a failure. Every other token consumes
at least one byte, so an empty token list is the only way to finish
having consumed nothing.
Copilot AI review requested due to automatic review settings July 27, 2026 23:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Native Flink UNIX_TIMESTAMP Function

3 participants