peter-toth commented on code in PR #861:
URL:
https://github.com/apache/spark-kubernetes-operator/pull/861#discussion_r4069330734
##########
docs/configuration.md:
##########
@@ -105,6 +107,45 @@ Since `,` is the separator, an expression cannot contain
it, e.g. `{1,3}`. An in
is logged and matches only the `reason` identical to it. Note that `*` is not
a wildcard but an
invalid expression, so use `.*` to exclude all reasons.
+### Event frequency
+
+Independently of that, the operator publishes an event with the same `reason`
**and the same
+`message`** on the same resource at most once every
+`spark.kubernetes.operator.events.minIntervalSeconds` (5 minutes by default,
dynamically
+overridable). The two options differ in kind: `excludedReasons` blocks a
`reason` permanently,
+while this one only limits how often an unchanged event is repeated. Set it to
`0` to publish
+every event.
+
+```properties
+spark.kubernetes.operator.events.minIntervalSeconds=300
+```
+
+The first event of a `reason` on a resource is always published right away, so
a rare event such
+as a failure is never delayed. Only a repeat that says exactly what the last
one said is dropped:
+such a repeat merely bumps the `count` of the one `Event` object, yet still
costs a read and a
+write on the API server. A repeat whose `message` differs is always published,
because the
+operator rewrites the `message` of the existing `Event`, so the current cause
of a failure and the
+`SuspendHeld` message that retracts a stale `KueueAdmissionPending` keep
reaching the user. The
+resource is identified by its `metadata.uid`, so a resource that reuses the
name of a deleted one
+starts over.
+
+An event is only published when a reconciliation emits it, so the effective
period is this
+interval **rounded up to the next repeat**, not the interval itself. With the
defaults,
+`KueueAdmissionPending` is emitted every 120 seconds
+(`spark.kubernetes.operator.reconciler.intervalSeconds`), so a 300 second
interval publishes it
+every 360 seconds, and `SuspendHeld` is emitted every 1800 seconds
+(`spark.kubernetes.operator.reconciler.suspendHoldRequeueIntervalSeconds`), so
it is unaffected.
+
+Keep the interval **below the interval at which the repeats themselves are
emitted**, i.e. below
Review Comment:
**Finding 1.** "below both of the two options named above" means below
`spark.kubernetes.operator.reconciler.intervalSeconds`, which is 120. The
default of this option is 300, so the rule declares the shipped default a
misconfiguration, and five lines earlier the same section says that default is
fine. A user who follows the rule sets the interval under 120, at which point
every repeat is published and the pacing stops applying to
`KueueAdmissionPending` — the only event the change affects at defaults, per
the PR description.
The example given is sound, but it only supports the rule for `SuspendHeld`.
I measured all four cases on the timed recorder, recording a repeat every `R`
seconds for one hour and listing the publishes:
```
I=300 R=120 [0, 360, 720, 1080, 1440, 1800, 2160, 2520, 2880, 3240, 3600]
I=2400 R=1800 [0, 3600]
I=1800 R=1800 [0, 1800, 3600]
I=1801 R=1800 [0, 3600]
```
So the hazard is not `I` versus `R`, it is the effective period
`ceil(I/R)*R` reaching the TTL. For `SuspendHeld` at `R=1800` that does bind at
`I <= 1800`, exactly 15x looser than "below 120", and the last two rows show
the boundary is precisely there. For `KueueAdmissionPending` at `R=120` it
binds at `I <= 3480`.
`ceil(I/R)*R < I + R`, so one rule covers both without the reader doing
arithmetic:
```
Keep `minIntervalSeconds` plus the interval at which the repeats are emitted
within the
`--event-ttl` of the API server (one hour by default), i.e. below
`3600 -
spark.kubernetes.operator.reconciler.suspendHoldRequeueIntervalSeconds` for
`SuspendHeld`
and below `3600 - spark.kubernetes.operator.reconciler.intervalSeconds` for
`KueueAdmissionPending`. With the defaults that is 1800 seconds, so the
default 300 leaves plenty
of room. At `minIntervalSeconds=2400` the `SuspendHeld` repeat at 1800
seconds is dropped and the
next one lands at 3600 seconds, exactly the TTL, so the event expires in
between and the hold
stops being visible.
```
The same rule is stated in two more places and needs the same correction:
`SparkOperatorConf.java:373-374` in the option description, and
`docs/config_properties.md` which is generated from it. The reciprocal clause
added to `SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS` ("keep `minIntervalSeconds`
below this interval") is fine as it stands, since `I <= R` is what binds for
that reason.
##########
tests/e2e/helm/events-config-values.yaml:
##########
@@ -19,3 +19,6 @@ operatorConfiguration:
# The suspend-events suite asserts that a held resource republishes its
SuspendHeld event.
# The default is 1800 seconds, which no assert can wait for.
spark.kubernetes.operator.reconciler.suspendHoldRequeueIntervalSeconds=10
+ # The repeat above carries the same message, so the default 300 second
minimum interval would
+ # drop every one of them and the count would never rise within the assert
timeout.
+ spark.kubernetes.operator.events.minIntervalSeconds=0
Review Comment:
**Finding 2.** `0` takes the `minInterval <= 0` early return, so
`suspend-events` — the only suite that installs the operator with events
enabled — exercises none of the new code. The map, the `compute`, the sweep and
the interval comparison are all skipped, and the unit tests are the sole
coverage.
A small positive value gets the coverage for free, because the pacing is a
no-op whenever the interval is below the emit interval. With
`suspendHoldRequeueIntervalSeconds=10` already set two lines up, I ran
`minIntervalSeconds=5` for 120 seconds of repeats:
```
PROBE I=5 R=10 publishes at >>> [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100,
110, 120]
PROBE tracked entries >>> 1
```
Every repeat still publishes, so the `count` rises exactly as it does at `0`
and the existing assert is unaffected, while the positive-interval path now
runs on a real cluster with a real `uid`. That last part is the bit unit tests
cannot cover: `uidOf` reads
`context.getPrimaryResource().getMetadata().getUid()`, and every unit test
hands it a hand-built `SparkApplication` rather than one that came back from
the API server.
```suggestion
# A small positive value keeps every repeat published, since the pacing
is a no-op below the
# requeue interval above, while still exercising the minimum-interval
path on a real resource.
spark.kubernetes.operator.events.minIntervalSeconds=5
```
##########
spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/ConfigurableEventRecorder.java:
##########
@@ -115,6 +166,107 @@ private static boolean isExcluded(String reason) {
.anyMatch(regex -> matches(regex, reason));
}
+ /**
+ * Returns whether the given event was already recorded on the given
resource less than {@link
+ *
org.apache.spark.k8s.operator.config.SparkOperatorConf#KUBERNETES_EVENTS_MIN_INTERVAL_SECONDS}
+ * ago. The first record of a {@link RecordedEvent} always returns false, so
a rare event such as
+ * a failure is never delayed. The uid rather than the name identifies the
resource, since a name
+ * can be reused by a later resource.
+ *
+ * <p>The message is part of the identity because {@link DefaultEventSink}
rewrites the message
+ * and the timestamp of the existing Event on every repeat, so only a repeat
that says exactly
+ * what the last one said is free of new information. A reason whose message
varies between
+ * repeats, such as one embedding the cause of a failure, keeps reaching the
user.
+ *
+ * @param event The event to record.
+ * @param uid The uid of the resource the event is about, null when it
cannot be determined.
+ * @return Whether the event is a repeat that should be dropped.
+ */
+ private boolean withinMinInterval(EventRecord event, String uid) {
+ long minInterval = minIntervalNanos();
+ if (minInterval <= 0L) {
+ // Limiting is off, so the state is dropped rather than left behind with
nothing to sweep it.
+ discardIntervalState();
+ return false;
+ }
+ if (uid == null) {
+ // The resource cannot be identified, so the event is published rather
than attributed to
+ // the wrong resource. Every event the operator publishes is about a
primary resource.
+ return false;
+ }
+ long now = nanoTime.getAsLong();
+ sweep(now, minInterval);
+ AtomicBoolean admitted = new AtomicBoolean();
+ // compute is atomic per key, so concurrent reconcile threads cannot both
admit the same event.
+ lastRecorded.compute(
+ new RecordedEvent(uid, event.reason(), event.message()),
+ (key, previous) -> {
+ if (previous == null || now - previous >= minInterval) {
+ admitted.set(true);
+ return now;
+ }
+ return previous;
+ });
+ return !admitted.get();
+ }
+
+ /**
+ * Drops the interval state. Guarded on {@link Map#isEmpty()} because this
runs on every event
+ * while limiting is off, while {@link ConcurrentHashMap#clear()} walks the
whole table, which the
+ * map never shrinks: clearing an already empty map that once held many
entries is not free.
+ */
+ private void discardIntervalState() {
+ if (!lastRecorded.isEmpty()) {
+ lastRecorded.clear();
+ }
+ }
+
+ /**
+ * Drops the entries older than the minimum interval, at most once per
interval. An entry that old
+ * admits the next event anyway, so removing it changes no decision, which
is why the map can be
+ * kept to the events recorded within the last interval instead of growing
with every resource the
+ * operator has ever seen. Sweeping inline avoids a background thread for a
map that is only ever
+ * touched while an event is published.
+ *
+ * <p>The due time is the elapsed time since the last sweep rather than a
deadline computed from
+ * the interval in force when that sweep ran. A deadline would keep a
lowered interval waiting
+ * out the old one, and would overflow for an interval large enough to
saturate {@link
+ * TimeUnit#toNanos}, leaving the map unswept for the life of the process.
+ *
+ * @param now The current time, in nanoseconds.
+ * @param minInterval The minimum interval between two events, in
nanoseconds.
+ */
+ private void sweep(long now, long minInterval) {
+ long previous = lastSweep.get();
+ // Only the thread that wins the compareAndSet sweeps, the others carry on
recording.
+ if (now - previous >= minInterval && lastSweep.compareAndSet(previous,
now)) {
+ lastRecorded.entrySet().removeIf(entry -> now - entry.getValue() >=
minInterval);
+ }
+ }
+
+ /**
+ * Returns how many events are currently tracked for the minimum interval.
The sweep has no
+ * effect on which events are published, so only this makes it observable to
a test.
+ *
+ * @return The number of tracked events.
+ */
+ int trackedEventCount() {
+ return lastRecorded.size();
+ }
+
+ private static String uidOf(Context<?> context) {
+ HasMetadata resource = context.getPrimaryResource();
+ ObjectMeta metadata = resource == null ? null : resource.getMetadata();
+ return metadata == null ? null : metadata.getUid();
+ }
+
+ private static long minIntervalNanos() {
+ Long seconds = KUBERNETES_EVENTS_MIN_INTERVAL_SECONDS.getValue();
+ // An unparseable override falls back to the default, but an override of
the literal 'null'
+ // resolves to null, which is treated as no limit rather than throwing out
of a reconciliation.
+ return seconds == null ? 0L : TimeUnit.SECONDS.toNanos(seconds);
Review Comment:
**Finding 3.** The comment is exactly right, which is why the branch
deserves the same test `KUBERNETES_EVENTS_ENABLED` got. I confirmed both halves
of it:
```
PROBE-NULL-RESOLVES-TO >>> null
PROBE-GARBAGE-RESOLVES-TO >>> 300
PROBE-NULL-PUBLISHES-BOTH >>> confirmed
```
`ConfigOption.resolveValue` sends a non-primitive type through
`objectMapper.readValue`, so `"null"` deserialises to `null` while
`"not-a-number"` throws `JsonProcessingException` and falls back to the default.
What makes it worth pinning is that the two options fail in *opposite*
directions on the same input: `eventsEnabled()` uses `Boolean.TRUE.equals`, so
a `"null"` override stops every event, while here it removes the limit
entirely. Both are the right choice for their option, and only one of them is
currently a test:
```java
@Test
void treatsAMalformedMinIntervalOverrideAsNoLimit() {
// The option is a boxed Long, so a malformed override can resolve to
null. Unlike the enabled
// flag, which then drops every event, a limit that cannot be read must
not drop any.
SparkOperatorConfManager.INSTANCE.refresh(
Map.of(
KUBERNETES_EVENTS_ENABLED.getKey(), "true",
KUBERNETES_EVENTS_MIN_INTERVAL_SECONDS.getKey(), "null"));
Context<?> context = contextOf("uid-1");
EventRecord first = EventRecord.normal("KueueAdmissionPending",
"queued");
EventRecord second = EventRecord.normal("KueueAdmissionPending",
"queued");
timedRecorder.record(first, context);
timedRecorder.record(second, context);
verify(delegate).record(first, context);
verify(delegate).record(second, context);
}
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]