feat: implement activity rules in controller - #1279
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
edfc6ba to
21eeeec
Compare
|
/retest |
Signed-off-by: siyuanfoundation <sizhang@google.com>
21eeeec to
6d0cf15
Compare
|
/retitle feat: implement activity rules in controller |
christian-heusel
left a comment
There was a problem hiding this comment.
Hey @siyuanfoundation, this is really awesome stuff, thanks for pushing this forward 🔥
I don't have any big comments, but noted down a few things that struck me as odd or small improvements, feel free to apply as sensible to you. Of course for the final review the WG leads and potentially other controller experts can chime in! 🤗
Generally I found that we still mix the naming from culling and activityProbe, I think that should be standardized before merge 😄
| // allow multiple Workspaces to be reconciled (and probed) in parallel so that a | ||
| // slow activity probe does not block other Workspaces' reconciliation | ||
| MaxConcurrentReconciles: maxConcurrentReconciles, |
There was a problem hiding this comment.
I looked into this a bit because I feared that allowing concurrent reconciles might introduce a race, however I convinced myself that this is not an issue since both the ws and i.e. the sts will collapse to the same key (namespace/X) in the workqueue and there is only one key being worked on at the time.
Is this assesment correct?
| return ctrl.Result{}, nil | ||
| } | ||
|
|
||
| // snapshot the Workspace as fetched, so the culling logic can issue a minimal |
There was a problem hiding this comment.
| // snapshot the Workspace as fetched, so the culling logic can issue a minimal | |
| // snapshot the Workspace as fetched, so the activityProbe logic can issue a minimal |
There was a problem hiding this comment.
Given that the feature was renamed to activityProbe this should most likely also be reflected in the filename and throughout it (i.e. in reconcileActivityCulling, etc.)
There was a problem hiding this comment.
workspace_activity.go probably a good name... and yes.. culling is a specific feature that can be configured with Activity Probe + Activity Rules - we we'd want files and functions ideally to not reference culling in the name..
user defines activity probe(s) and activity rule(s) - and an outcome of those configurations could be that a workspace is culled.. but in future - if/as we add other "outcomes" - we wouldn't want Culling in any named files/functions as that would get confusing..
| runningDurationMs := now - lastRunningTime | ||
| if runningDurationMs < int64(minRunningSeconds)*millisPerSecond { | ||
| return false, eligibleAfter | ||
| } |
There was a problem hiding this comment.
Maybe we could do something like this and get rid of the millisPerSecond?
runningDuration := time.Duration(now-lastRunningTime) * time.Millisecond
if runningDuration < time.Duration(minRunningSeconds)*time.Second {
return false, eligibleAfter
}| if lastActivity <= 0 { | ||
| return 0 | ||
| } | ||
| return lastActivity + int64(secondsSinceActive)*millisPerSecond |
There was a problem hiding this comment.
| return lastActivity + int64(secondsSinceActive)*millisPerSecond | |
| return lastActivity + (time.Duration(secondsSinceActive) * time.Second).Milliseconds() |
| func EvaluateActivityRule[T any]( | ||
| rules []kubefloworgv1beta1.ActivityRule, | ||
| namespaceLabels, podConfigLabels map[string]string, | ||
| getEffect func(kubefloworgv1beta1.ActivityRuleEffect) *T, | ||
| ) ActivityRuleDecision[T] { |
There was a problem hiding this comment.
I'm curious, why did you decide to make EvaluateActivityRule() generic? 🤔 AFAIU it can currently only ever be a bool?
| type ProbeResult struct { | ||
| // StartTime is the time the probe was started (UNIX epoch in milliseconds). | ||
| StartTime int64 | ||
|
|
||
| // EndTime is the time the probe completed (UNIX epoch in milliseconds). | ||
| EndTime int64 | ||
|
|
||
| // Result is the outcome of the probe (Success, Failure, or Timeout). | ||
| Result kubefloworgv1beta1.WorkspaceProbeResult | ||
|
|
||
| // Message is a human-readable message about the probe result. | ||
| Message string | ||
|
|
||
| // LastActivity is the activity timestamp determined by the probe (UNIX epoch in | ||
| // milliseconds). It is nil when the probe did not succeed or when the activity timestamp | ||
| // should not be updated. | ||
| LastActivity *int64 | ||
| } |
There was a problem hiding this comment.
Any reason you chose the time-related fields in ProbeResult to be of type int64? I would have imagined that since we use this type internally as a helper its easier to use the native go time type and then convert via UnixMilli(int64) / time.UnixMilli() as needed 🤔
| shellCommand := fmt.Sprintf( | ||
| "cat > %[1]s && chmod +x %[1]s && OUTPUT_JSON_PATH=%[2]s %[1]s; rc=$?; rm -f %[1]s; "+ | ||
| "if [ $rc -ne 0 ]; then exit $rc; fi; "+ | ||
| "if [ -f %[2]s ]; then cat %[2]s; rm -f %[2]s; fi", | ||
| scriptPath, outputPath, | ||
| ) | ||
| command := []string{"/bin/sh", "-c", shellCommand} |
There was a problem hiding this comment.
Even though scriptPath can not contain spaces ([/.a-z0-9-]) we should apply proper variable quoting in this section, since paths generally can have spaces.
There was a problem hiding this comment.
$ shellcheck test.sh
In test.sh line 2:
cat > $1 && chmod +x $1 && OUTPUT_JSON_PATH=$2 $1; rc=$?; rm -f $1;
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
cat > "$1" && chmod +x "$1" && OUTPUT_JSON_PATH=$2 $1; rc=$?; rm -f "$1";
In test.sh line 4:
if [ -f $2 ]; then cat $2; rm -f $2; fi
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
if [ -f "$2" ]; then cat "$2"; rm -f "$2"; fi
For more information:
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
|
/assign |
andyatmiami
left a comment
There was a problem hiding this comment.
@siyuanfoundation ... don't let number of comments here "get it twisted" - this is a very impressive contribution and its obvious the effort/attention you put into this - so thank you.
Aside from the comments I have explicitly called out in the review - I also wanetd to flag a general pattern..
There are 15+ error and result message strings scattered across RunJupyterProbe, RunPodExecProbe, runProbe, and parsePodExecOutput, many sharing repeated prefixes like "Jupyter probe failed: " and "PodExec probe failed: ". Extracting the prefixes and common messages as constants at the top of the file (alongside the existing maxProbeResponseBytes, probeContainerName, jupyterStatusPath constants) would:
- Make the full message contract visible at a glance
- Reduce prefix duplication
- Make it easier to keep messaging consistent if new probe types are added
ℹ️ As always - feel free to question/push back on any of these comments... I did a lot of staring at call stacks while reviewing - so its possible I am just "confused" on some of my feedback - keep that in mind 😇
| Config *config.EnvConfig | ||
|
|
||
| // PodExecutor executes activity probe scripts inside Workspace Pods. | ||
| // If nil, podExec probes are skipped (used when exec is not configured, e.g. in some tests). |
There was a problem hiding this comment.
This comment seems misleading - as if I am reading this code corrected... we return a Failure result:
Perhaps that is technically "skipping" - but maybe a little more specificity here would be good
| // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=create;delete;get;list;patch;update;watch | ||
| // +kubebuilder:rbac:groups=core,resources=events,verbs=get;list;watch | ||
| // +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch | ||
| // +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch |
There was a problem hiding this comment.
Maybe for @thesuperzapper to weigh in here...
It makes sense we'd use the controller-runtime's cached client here.. but we are also now introducing a shared informer that list+watches all namespaces cluster-wide..
Are we concerned about cardinality here?
| } | ||
| workspace.Status = workspaceStatus | ||
|
|
||
| // reconcile the activity probe and culling rules |
There was a problem hiding this comment.
| // reconcile the activity probe and culling rules | |
| // reconcile the activity probe and activity rules |
| if workspaceState == kubefloworgv1beta1.WorkspaceStateRunning { | ||
| if workspace.Status.State != kubefloworgv1beta1.WorkspaceStateRunning || status.LastRunningTime == 0 { | ||
| status.LastRunningTime = metav1.Now().UnixMilli() | ||
| status.Activity = kubefloworgv1beta1.WorkspaceActivity{} | ||
| } | ||
| } |
There was a problem hiding this comment.
Doesn't seem like we need nested-ifs here (and perhaps a helper function to implement the conditional would be beneficial and a more nature place to add these helpful inline comments..
There was a problem hiding this comment.
workspace_activity.go probably a good name... and yes.. culling is a specific feature that can be configured with Activity Probe + Activity Rules - we we'd want files and functions ideally to not reference culling in the name..
user defines activity probe(s) and activity rule(s) - and an outcome of those configurations could be that a workspace is culled.. but in future - if/as we add other "outcomes" - we wouldn't want Culling in any named files/functions as that would get confusing..
| if ns.Labels == nil { | ||
| return map[string]string{}, nil | ||
| } | ||
| return ns.Labels, nil |
There was a problem hiding this comment.
The Namespace object is from the controller-runtime cache. While current versions deep-copy on Get, returning maps.Clone(ns.Labels) would make the safety guarantee explicit and future-proof.
|
|
||
| // selectorMatches converts a metav1.LabelSelector and evaluates it against the given labels. | ||
| func selectorMatches(selector *metav1.LabelSelector, lbls map[string]string) (bool, error) { | ||
| sel, err := metav1.LabelSelectorAsSelector(selector) |
There was a problem hiding this comment.
EvaluateActivityRule calls metav1.LabelSelectorAsSelector() per rule per reconcile, parsing and compiling the selector each time. For a small number of rules this is fine, but with thousands of workspaces all referencing the same WorkspaceKind, the same selectors are recompiled on every reconcile. Not a blocker — selector compilation is fast — but at extreme scale (10K+ workspaces, frequent probes), caching compiled selectors per WorkspaceKind would eliminate redundant work.
| probeInterval := time.Duration(ptr.Deref(activityProbe.ProbeIntervalSeconds, kubefloworgv1beta1.DefaultProbeIntervalSeconds)) * time.Second | ||
|
|
||
| // gather the labels used by activity rule matching. | ||
| namespaceLabels, err := r.getNamespaceLabels(ctx, workspace.Namespace) |
There was a problem hiding this comment.
ℹ️ getNamespaceLabels is called on every reconcile regardless of whether any activityRules define a matchNamespace.
I just want to transparently call this out - but I don't think the additional complexity is warranted to try and defer this call - as then we'd seemingly be required to iterate rules 2x... negating any desired performance benefit.
|
|
||
| matched, err := activityRuleMatches(rule.Match, namespaceLabels, podConfigLabels) | ||
| if err != nil { | ||
| // treat an invalid selector as non-matching (validation should prevent this). |
There was a problem hiding this comment.
Part of me would want at least a debug log (or something) here to alert us if we someone entered this logic branch.... but then threading a logger into this function seems overkill...
WDYT?
| TTY: false, | ||
| }, scheme.ParameterCodec) | ||
|
|
||
| exec, err := remotecommand.NewSPDYExecutor(e.RestConfig, http.MethodPost, req.URL()) |
There was a problem hiding this comment.
This project currently targets Kubernetes 1.31+ and uses client-go v0.31.0, where WebSocket-based exec is GA. This proposed implementation uses remotecommand.NewSPDYExecutor (the legacy SPDY protocol), which still works but is the older path. The Kubernetes ecosystem is moving toward WebSocket exec, and some managed Kubernetes offerings may eventually deprecate the SPDY exec endpoint. Consider using NewFallbackExecutor which tries WebSocket first and falls back to SPDY for broader compatibility
closes: #867
This PR implements the following:
Tested with tilt using JupyterLab WorkspaceKind.
eligibleAfteris properly populated, and the workspace is paused after being idle exceeding thesecondsSinceActive