1

Unable to assign a variable within an $function definition, is there a workaround for this?

This works:

$GetADef = "function ${Task} {$function:ActualFunctionName}"

Using a variable $Var for example fails:

$GetADef = "function ${Task} {$function:$Var}"

$function:${Var}
     |  ~~~~~~~~~~
     | Variable reference is not valid. ':' was not followed by a valid variable name
     | character. Consider using ${} to delimit the name.

My undesired approach:

The following is a stripped down section where I am calling a start job for a server with a particular job I want it to run. The following works, it's reading from a queue of jobs which is a list of hash tables.

$HealthCheckJobs = foreach ($Task in $Queue) {
    
    [string]$JobTask = 'HealthCheck' + $Task.Name
    [string]$JobHost = $Task.Host
    [string]$JobName = "HealthCheckJobs-" + $Task.Host + "-" + $Task.Name 

    # I would prefer to remove this section
    Switch ($Task.Name) {
        "DRIVES" { $FuncDef = "function HealthCheckDrives {$function:HealthCheckDRIVES}" }
        "CPU" { $FuncDef = "function HealthCheckCPU {$function:HealthCheckCPU}" }
    }
    # end of what I want to remove
         
    Start-Job -Name $JobName {
        . ([scriptblock]::Create($using:FuncDef)) 
        return &$using:JobTask -Target $using:JobHost
    } 

}

$Results += $HealthCheckJobs | Receive-Job -Wait  # => Collect

UPDATE: Suggested Solution

Thank you for your help! Using the suggested solution, I put together a working example. The example shows threads starting by number and finally printing the results from the jobs. Additionally I added "MaxWorker" check that controls to some degree the number of concurrent threads. That was a quick hack that could probably be improve upon.

Sample working code:

function HealthCheckDrive {
    param($Target)
    start-sleep -Seconds 10
    Return "[HealthCheckDrive]: " + $Target + " - ID - " + [string]([guid]::NewGuid()).Guid; # just for display, no meaning
}

function HealthCheckCPU {
    param($Target)
    start-sleep -Seconds 10
    Return "[HealthCheckCPU]: " + $Target + " - ID - " + [string]([guid]::NewGuid()).Guid; # just for display, no meaning

}

$Targets = @("Server1", "Server2", "Server3", "Server4", "Server5", "Server6", "Server7", "Server8")
$Queue = @()
ForEach ($Target in $Targets) {

    $Queue += [pscustomobject]@{
        Action = 'HealthCheckDrive' # Function to Invoke here
        Host   = $Target
    }
    $Queue += [pscustomobject]@{
        Action = 'HealthCheckCPU'   # Function to Invoke here
        Host   = $Target
    }
}

$isShowProgress = $true
If ($isShowProgress) {
    Write-Host("Launching: [" + $Queue.Length + "] Workers:`n [") -NoNewline
    [int]$C = 1
}

[string]$JobName = "SystemCheckJobs" # giving jobs the same name for rough accounting
[int]$MaxWorkers = 9 #arbitrary limit for concurrent jobs

# This hash is used to hold each function definition so that if
# the same function is invoked more than once there there is no need
# to `Get-Command` more than once
$commandDefinitions = @{}
$WorkerJobs = foreach ($Task in $Queue) {
    # If we already have the Definition for this Action
    if($commandDefinitions.ContainsKey($Task.Action)) {
        $def = $commandDefinitions[$Task.Action]  # use it
    }
    else {

        # else, get it and set it
        $def = (Get-Command $Task.Action -CommandType Function).Definition
        $commandDefinitions[$Task.Action] = $def
    }

    If ($isShowProgress) {
        Write-Host(" " + $C) -NoNewline ; $C += 1  ; Start-Sleep -Milliseconds (Get-Random -Minimum 50 -Maximum 200) # visual
    }

    Start-Job -Name $JobName {
        & ([scriptblock]::Create($using:def)) -Target $using:Task.Host
    }

    # wait to start new jobs until count goes down (or this times out)
    $AutoTimeOut = 0
    While ($AutoTimeOut -lt 10) {
        $running = Get-Job | Where-Object { $_.Name.Contains($JobName) -And $_.State.Contains("Running") }
        if ($running.Count -ge $MaxWorkers) { 
            Start-Sleep -Seconds 1
            $AutoTimeOut += 1 
        } Else {
            Break
        }
    }
    
}
If ($isShowProgress) {Write-Host " ] (Please Wait)"}
$WorkerJobs | Receive-Job -Wait -AutoRemoveJob # => Collect
Mike Q
  • 6,716
  • 5
  • 55
  • 62
  • 1
    Why do you need to do this? I assume you're looking to redefine this function in a different scope but if so, you can simply do `${function:My-Func} = { # definition here }` – Santiago Squarzon Feb 25 '23 at 18:34
  • @SantiagoSquarzon. I am trying to create a queue of tasks that run through a generic "Start-Job" loop. The start-job will be dynamically used to call one of multiple functions based on a queued item. In short I am creating a queue of tasks then trying to push them all through "Start-Job". It would be a bit less code if I can dynamically create these function defs as the jobs are needed. – Mike Q Feb 25 '23 at 19:56
  • 1
    Can you update or create a new question to show a minimal example of what you mean? If I understand correctly, I believe its much simpler than what you're attempting to do – Santiago Squarzon Feb 25 '23 at 19:59
  • 1
    I included the code that I'm having an issue with, I wanted to have a master loop to feed these jobs so I can control the max number running at a given time. I could start jobs for each task at the same time I suppose but I don't find that as straight forward etc.. – Mike Q Feb 25 '23 at 21:06
  • 2
    I see its much clearer now, so if you want to remove the `switch` part of your code, then you would need to update your `$Task` having a new property that has at least the name of the function to invoke or the function definition to invoke, that would make it much simpler. I can post an answer showing what I mean – Santiago Squarzon Feb 25 '23 at 21:15
  • Thanks for responding, the things that I have tried haven't worked .. Is there a site I can share a sample code that you know of? – Mike Q Feb 25 '23 at 21:57

1 Answers1

1

Here is a self-contained example of what I think you're trying to achieve, basically, there are 2 simple functions which are passed to the Job's scope, then are dynamically defined in that scope and dynamically invoked.

First define 2 simple functions in the parent scope:

function HealthCheckDrive {
    param($Thing)
    "Drive Health Check for $Thing"
}

function HealthCheckCPU {
    param($Thing)
    "CPU Health Check for $Thing"
}

Then, for the $queue variable, for this to work properly each $Task would need to have a property (Action in this case) that uses the exact name of the function to invoke in the Job's scope. More properties can be added as needed, but one of the properties must have the function name to be invoked.

$queue = @(
    [pscustomobject]@{
        Action = 'HealthCheckDrive' # Function to Invoke here
        Host   = 'Computer1'
    }
    [pscustomobject]@{
        Action = 'HealthCheckCPU'   # Function to Invoke here
        Host   = 'Computer2'
    }
)

Lastly, the logic used to loop and invoke the Jobs, as you may see is pretty similar to what you were trying to achieve:

# This hash is used to hold each function definition so that if
# the same function is invoked more than once there there is no need
# to `Get-Command` more than once
$commandDefinitions = @{}
$HealthCheckJobs = foreach ($Task in $Queue) {
    # If we already have the Definition for this Action
    if($commandDefinitions.ContainsKey($Task.Action)) {
        # use it
        $def = $commandDefinitions[$Task.Action]
    }
    else {
        # else, get it
        $def = (Get-Command $Task.Action -CommandType Function).Definition
        # and set it
        $commandDefinitions[$Task.Action] = $def
    }

    Start-Job {
        & ([scriptblock]::Create($using:def)) -Thing $using:Task.Host
    }
}

$HealthCheckJobs | Receive-Job -Wait -AutoRemoveJob # => Collect

Something like:

Start-Job ([scriptblock]::Create($def)) -ArgumentList $Task.Host

Would also work however this is a bit more limited, when using -ArgumentList parameters are passed positionally. Up to you how you want to do it.

Another option, if you can pass the definitions of the functions to invoke in the Action property then this becomes even more simple:

$queue = @(
    [pscustomobject]@{
        Action = ${function:HealthCheckDrive}
        Host   = 'Computer1'
    }
    [pscustomobject]@{
        Action = ${function:HealthCheckCPU}
        Host   = 'Computer2'
    }
)

$HealthCheckJobs = foreach ($Task in $Queue) {
    Start-Job $Task.Action -ArgumentList $Task.Host
}

$HealthCheckJobs | Receive-Job -Wait -AutoRemoveJob # => Collect
Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37
  • 1
    I tried both options and they both work.. Thank you! I am going to update my post with my sample code. – Mike Q Feb 26 '23 at 15:31
  • 1
    @MikeQ I see you have integrated a timeout and progress to your running jobs, the function from this answer might be useful: https://stackoverflow.com/questions/71918277/add-write-progress-to-get-job-wait-job/71918903#71918903 – Santiago Squarzon Feb 26 '23 at 17:07
  • Is it possible to call these functions from a class ? Should I create a new question for that? For example: Class SystemHealth {}. – Mike Q Feb 26 '23 at 19:55
  • 1
    @MikeQ classes need to be defined in the jobs scriptblock unfortunately, this won't work. The easiest work arround is to dot source the class definition from a file inside the jobs scriptblock. You can ask a new question to see how you're approaching it but the solution its not gonna be pretty – Santiago Squarzon Feb 26 '23 at 20:03