Weiqing Yang created FLINK-41450:
------------------------------------
Summary: Release Testing: Verify FLIP-485: Add UDF Metrics
Key: FLINK-41450
URL: https://issues.apache.org/jira/browse/FLINK-41450
Project: Flink
Issue Type: Sub-task
Reporter: Weiqing Yang
Fix For: 2.4.0
[FLIP-485|https://cwiki.apache.org/confluence/display/FLINK/FLIP-485%3A+Add+UDF+Metrics]
adds per-function operator metrics for SQL/Table user-defined functions. They
are off by default and enabled with {{table.exec.udf-metric-enabled}}. Each
function gets two metrics on the operator that runs it, under {{<operator
scope>.udf.<udf_name>}}:
* {{udfProcessingTime}}: a histogram in nanoseconds. Only one call in every
{{table.exec.udf-metric.sample-interval}} (default 100) is timed. For
asynchronous functions it spans dispatch to completion.
* {{udfExceptionCount}}: a counter of exceptions escaping the function, counted
on every call. For asynchronous functions it counts exceptional completions.
Docs: [UDF
metrics|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/ops/metrics/#udf-metrics]
and the
[UDF|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/ops/metrics/#udf]
section of the system metrics. Unit and IT tests cover the code paths. This
ticket is about seeing the metrics on a real cluster for each kind of function:
synchronous and asynchronous, scalar and table.
h3. Known issues (already filed, fixes in progress)
These came up in pre-testing and are tracked for 2.4.0. There is no need to
file them again. If you see different behavior, comment on the ticket.
* FLINK-41447: with metrics enabled, a
{{table.exec.udf-metric.sample-interval}} below 1 is accepted when the query is
submitted, and the job then restarts in a loop instead of being rejected.
* FLINK-41448: the metric group is named after the spelling used in the query,
so {{myFunc(x)}} and {{MYFUNC(x)}} on one function create two groups.
h3. 0. Setup
Build {{release-2.4}}, or use the 2.4.0 RC. The steps read metrics through the
REST API with {{curl}} and {{jq}}. If you want the Web UI as well, drop
{{\-Pskip-webui-build}}.
{code:bash}
./mvnw clean install -DskipTests -Dfast -Pskip-webui-build -T1C
cd build-target
mkdir -p /tmp/flip485/src/flip485 /tmp/flip485/classes
{code}
In {{conf/config.yaml}}, change {{numberOfTaskSlots: 1}} to {{10}} and the
TaskManager {{process.size: 1728m}} to {{4096m}} (steps 1 to 7 run nine small
jobs side by side). Then append the Slf4j reporter, which is only needed for
steps 5 and 6:
{code:yaml}
metrics.reporter.slf4j.factory.class:
org.apache.flink.metrics.slf4j.Slf4jReporterFactory
metrics.reporter.slf4j.interval: 10 SECONDS
{code}
Create the test functions and compile them against the distribution. Run this
from {{build-target}}:
{code:bash}
cat > /tmp/flip485/src/flip485/SlowUpper.java <<'EOF'
package flip485;
import org.apache.flink.table.functions.ScalarFunction;
/** Synchronous scalar function that takes about 2 ms per call. */
public class SlowUpper extends ScalarFunction {
public String eval(String s) throws InterruptedException {
Thread.sleep(2);
return s == null ? null : s.toUpperCase();
}
}
EOF
cat > /tmp/flip485/src/flip485/Twice.java <<'EOF'
package flip485;
import org.apache.flink.table.functions.TableFunction;
/** Synchronous table function that emits every input twice. */
public class Twice extends TableFunction<Long> {
public void eval(Long x) { collect(x); collect(x); }
}
EOF
cat > /tmp/flip485/src/flip485/AsyncLen.java <<'EOF'
package flip485;
import org.apache.flink.table.functions.AsyncScalarFunction;
import org.apache.flink.table.functions.FunctionContext;
import java.util.concurrent.*;
/** Asynchronous scalar function that completes 50 ms after it is called. */
public class AsyncLen extends AsyncScalarFunction {
private transient ScheduledExecutorService executor;
@Override public void open(FunctionContext context) { executor =
Executors.newSingleThreadScheduledExecutor(); }
@Override public void close() { if (executor != null) {
executor.shutdownNow(); } }
public void eval(CompletableFuture<Integer> future, String s) {
executor.schedule(() -> future.complete(s.length()), 50,
TimeUnit.MILLISECONDS);
}
}
EOF
cat > /tmp/flip485/src/flip485/AsyncTwice.java <<'EOF'
package flip485;
import org.apache.flink.table.functions.AsyncTableFunction;
import org.apache.flink.table.functions.FunctionContext;
import java.util.*;
import java.util.concurrent.*;
/** Asynchronous table function that emits every input twice, 50 ms after it is
called. */
public class AsyncTwice extends AsyncTableFunction<Long> {
private transient ScheduledExecutorService executor;
@Override public void open(FunctionContext context) { executor =
Executors.newSingleThreadScheduledExecutor(); }
@Override public void close() { if (executor != null) {
executor.shutdownNow(); } }
public void eval(CompletableFuture<Collection<Long>> future, Long x) {
executor.schedule(() -> future.complete(Arrays.asList(x, x)), 50,
TimeUnit.MILLISECONDS);
}
}
EOF
cat > /tmp/flip485/src/flip485/AsyncFlaky.java <<'EOF'
package flip485;
import org.apache.flink.table.functions.AsyncScalarFunction;
import org.apache.flink.table.functions.FunctionContext;
import java.util.Set;
import java.util.concurrent.*;
/** The first attempt fails for every id divisible by 10; the operator's retry
then succeeds. */
public class AsyncFlaky extends AsyncScalarFunction {
private transient ScheduledExecutorService executor;
private transient Set<Long> failedOnce;
@Override public void open(FunctionContext context) {
executor = Executors.newSingleThreadScheduledExecutor();
failedOnce = ConcurrentHashMap.newKeySet();
}
@Override public void close() { if (executor != null) {
executor.shutdownNow(); } }
public void eval(CompletableFuture<Long> future, Long id) {
executor.schedule(() -> {
if (id % 10 == 0 && failedOnce.add(id)) {
future.completeExceptionally(new RuntimeException("first
attempt fails"));
} else {
future.complete(id);
}
}, 10, TimeUnit.MILLISECONDS);
}
}
EOF
javac -cp "lib/*" -d /tmp/flip485/classes /tmp/flip485/src/flip485/*.java
jar cf /tmp/flip485/flip485-udfs.jar -C /tmp/flip485/classes .
{code}
Save the following as {{/tmp/flip485/init.sql}}. Keep {{fields.id.end}} small:
the datagen sequence generator holds its whole range in memory.
{code:sql}
ADD JAR '/tmp/flip485/flip485-udfs.jar';
CREATE TEMPORARY SYSTEM FUNCTION slow_upper AS 'flip485.SlowUpper';
CREATE TEMPORARY SYSTEM FUNCTION twice AS 'flip485.Twice';
CREATE TEMPORARY SYSTEM FUNCTION async_len AS 'flip485.AsyncLen';
CREATE TEMPORARY SYSTEM FUNCTION async_twice AS 'flip485.AsyncTwice';
CREATE TEMPORARY SYSTEM FUNCTION async_flaky AS 'flip485.AsyncFlaky';
CREATE TABLE src (id BIGINT, name STRING) WITH (
'connector' = 'datagen', 'rows-per-second' = '20',
'fields.id.kind' = 'sequence', 'fields.id.start' = '1', 'fields.id.end' =
'100000',
'fields.name.length' = '8');
CREATE TABLE bh_str (s STRING) WITH ('connector' = 'blackhole');
CREATE TABLE bh_str2 (a STRING, b STRING) WITH ('connector' = 'blackhole');
CREATE TABLE bh_long (x BIGINT) WITH ('connector' = 'blackhole');
CREATE TABLE bh_int (n INT) WITH ('connector' = 'blackhole');
{code}
Save this helper as {{/tmp/flip485/udf-metrics.sh}} and {{chmod +x}} it. It
prints every UDF metric of every running job:
{code:bash}
#!/usr/bin/env bash
REST=${REST:-http://localhost:8081}
for jid in $(curl -s $REST/jobs | jq -r '.jobs[] | select(.status=="RUNNING") |
.id'); do
job=$(curl -s $REST/jobs/$jid)
echo "== $(echo "$job" | jq -r .name)"
for vid in $(echo "$job" | jq -r '.vertices[].id'); do
ids=$(curl -sg "$REST/jobs/$jid/vertices/$vid/metrics" | jq -r '.[].id' |
grep '\.udf\.' | paste -sd, -)
[ -z "$ids" ] && continue
curl -sg "$REST/jobs/$jid/vertices/$vid/metrics?get=$ids" | jq -r '.[] | "
\(.id) = \(.value)"' | sort
done
done
{code}
Start the cluster with {{./bin/start-cluster.sh}} and the SQL Client with
{{./bin/sql-client.sh \-i /tmp/flip485/init.sql}}. Run all statements below in
that session. Each {{INSERT INTO}} starts a detached job, and {{pipeline.name}}
names it so the helper output is easy to read.
The REST API serves metrics from a store that refreshes every 10 seconds. If a
job that just started prints no metric lines, run the helper again.
h3. 1. Metrics are off by default
{code:sql}
SET 'pipeline.name' = 'flip485-off';
INSERT INTO bh_str SELECT slow_upper(name) FROM src;
{code}
*Expected:* {{/tmp/flip485/udf-metrics.sh}} prints the {{== flip485-off}}
header and no metric lines.
h3. 2. Synchronous scalar and table functions
{code:sql}
SET 'table.exec.udf-metric-enabled' = 'true';
SET 'table.exec.udf-metric.sample-interval' = '1';
SET 'pipeline.name' = 'flip485-sync-scalar';
INSERT INTO bh_str SELECT slow_upper(name) FROM src;
SET 'pipeline.name' = 'flip485-sync-table';
INSERT INTO bh_long SELECT t.x FROM src, LATERAL TABLE(twice(id)) AS t(x);
{code}
*Expected:* the {{udfProcessingTime}} statistics ({{_min}}, {{_max}},
{{_mean}}, {{_median}}, {{_stddev}}, {{_p75}} to {{_p999}}) and
{{udfExceptionCount}} appear for both jobs, for example:
{noformat}
== flip485-sync-scalar
0.Calc[5].udf.slow_upper.udfExceptionCount = 0
0.Calc[5].udf.slow_upper.udfProcessingTime_mean = 2995844.07
0.Calc[5].udf.slow_upper.udfProcessingTime_min = 2191334
== flip485-sync-table
0.Correlate[8].udf.twice.udfExceptionCount = 0
0.Correlate[8].udf.twice.udfProcessingTime_mean = 923.15
{noformat}
{{slow_upper}} sleeps 2 ms, so its {{_min}} is at least 2,000,000 ns. {{twice}}
does no work, so it stays in the microsecond range. The operator ids in
brackets will differ.
h3. 3. Asynchronous scalar and table functions
{code:sql}
SET 'pipeline.name' = 'flip485-async-scalar';
INSERT INTO bh_int SELECT async_len(name) FROM src;
SET 'pipeline.name' = 'flip485-async-table';
INSERT INTO bh_long SELECT t.x FROM src, LATERAL TABLE(async_twice(id)) AS t(x);
{code}
*Expected:* metrics on the {{AsyncCalc}} and {{AsyncCorrelate}} operators,
under {{udf.async_len}} and {{udf.async_twice}}. Both functions complete 50 ms
after the call, so {{udfProcessingTime_min}} is at least 50,000,000 ns (larger
values may print in E-notation, such as {{5.67E7}}). This shows the timing
covers dispatch to completion, not just the {{eval}} call.
h3. 4. Failed asynchronous calls are counted and the job keeps running
{code:sql}
SET 'pipeline.name' = 'flip485-async-flaky';
INSERT INTO bh_long SELECT async_flaky(id) FROM src;
{code}
*Expected:* {{udf.async_flaky.udfExceptionCount}} grows by about one per ten
input rows (about two per second), and the job stays {{RUNNING}}. Every id
divisible by 10 fails its first attempt, and the retry
({{table.exec.async-scalar.max-attempts}}, default 3) succeeds.
h3. 5. Sampling
{code:sql}
RESET 'table.exec.udf-metric.sample-interval';
SET 'pipeline.name' = 'flip485-sampled-default';
INSERT INTO bh_str SELECT slow_upper(name) FROM src;
{code}
The REST API shows histogram statistics but not how many samples were taken, so
use the Slf4j reporter's {{count}}, which it logs every 10 seconds:
{code:bash}
grep -oE '[^ ]*(flip485-sync-scalar|flip485-sampled-default|flip485-shared)[^
]*udfProcessingTime: count=[0-9]+' log/flink-*-taskexecutor-*.log | tail -9
{code}
*Expected:* for {{flip485-sampled-default}} (default interval 100, 20 rows per
second) the count grows by about 2 per report, one sample every 5 seconds. For
{{flip485-sync-scalar}} (interval 1) it reaches 128 within seconds and stays
there, because the histogram keeps the latest 128 samples.
h3. 6. The same function used twice in one operator shares one metric group
Still at the default interval from step 5:
{code:sql}
SET 'pipeline.name' = 'flip485-shared';
INSERT INTO bh_str2 SELECT slow_upper(name), slow_upper(CAST(id AS STRING))
FROM src;
{code}
*Expected:* the helper shows a single {{udf.slow_upper}} group on this job's
{{Calc}}. Both call sites feed it, so in the grep from step 5 its count grows
about twice as fast as {{flip485-sampled-default}}'s: about 4 per report
instead of 2. Use different arguments as above. With identical arguments the
planner computes the call only once. Spell the function the same way at both
call sites. Different spellings currently create separate groups (FLINK-41448).
h3. 7. Batch mode
The two options also apply to batch jobs, although the configuration reference
labels them as streaming options. In the same session, with metrics still
enabled:
{code:sql}
CREATE TEMPORARY TABLE bsrc (name STRING) WITH (
'connector' = 'datagen', 'rows-per-second' = '20', 'number-of-rows' = '1200',
'fields.name.length' = '8');
SET 'execution.runtime-mode' = 'batch';
SET 'pipeline.name' = 'flip485-batch';
INSERT INTO bh_str SELECT slow_upper(name) FROM bsrc;
SET 'execution.runtime-mode' = 'streaming';
{code}
*Expected:* the job runs for about a minute, and while it runs the helper shows
{{udf.slow_upper}} on its {{Calc}}.
h3. Exploratory (optional)
* Turn {{table.exec.udf-metric-enabled}} off and on for the same query and
check the query results do not change.
* The docs list what is not covered: Python functions, {{PROCESS_TABLE}}
functions, aggregate functions, functions registered through the deprecated
{{registerFunction}}, and the functions inside lookup joins, model prediction
and vector search. Using them with metrics enabled should register nothing and
work as before.
* Run with parallelism > 1 and check that each subtask reports its own metrics.
h3. Known behavior (not bugs)
* An exception escaping a synchronous function still fails the task. It is
counted, but the task restarts with fresh metrics, so on a cluster the counter
is mainly useful for asynchronous functions whose failed attempts are retried.
* The REST API and Web UI show the histogram's statistics, not its sample
count. The histogram keeps the latest 128 samples.
* Metrics are scoped by the function's name, {{udf.<udf_name>}}, on each
operator that calls it.
* The group uses the function's simple name. Functions with the same name from
different catalogs or databases, such as {{db1.f}} and {{db2.f}}, share one
{{udf.f}} group on an operator that calls both.
* In the REST API and Web UI, dots and spaces in a function name become {{_}},
so functions named {{my.func}} and {{my func}} appear there as one
{{udf.my_func}} entry. The Slf4j reporter keeps them apart.
* A compiled plan does not store the metric settings. The values set when the
plan runs ({{EXECUTE PLAN}}) decide, not the values at {{COMPILE PLAN}} time.
* Not from this FLIP: calling two functions with the same name from different
databases on the same arguments in one query returns wrong results, with or
without metrics (FLINK-41449).
h3. Reporting
Comment with the commit or RC and give pass or fail per section. File new bugs
as linked issues with Fix Version 2.4.0.
--
This message was sent by Atlassian Jira
(v10.3.26#10030026)