From a8a8432a79f219e793c569378665a4815a1051da Mon Sep 17 00:00:00 2001 From: Alice Gibbons Date: Mon, 17 Feb 2025 14:28:21 +0000 Subject: [PATCH 1/5] Jobs SDK qs Signed-off-by: Alice Gibbons --- jobs/csharp/sdk/README.md | 179 ++++++++++++++++++ jobs/csharp/sdk/dapr.yaml | 16 ++ jobs/csharp/sdk/job-scheduler/Program.cs | 101 ++++++++++ .../sdk/job-scheduler/jobs-scheduler.csproj | 15 ++ jobs/csharp/sdk/job-service/Program.cs | 163 ++++++++++++++++ .../Properties/launchSettings.json | 38 ++++ .../job-service/appsettings.Development.json | 8 + jobs/csharp/sdk/job-service/appsettings.json | 9 + .../csharp/sdk/job-service/job-service.csproj | 14 ++ jobs/csharp/sdk/makefile | 2 + 10 files changed, 545 insertions(+) create mode 100644 jobs/csharp/sdk/README.md create mode 100644 jobs/csharp/sdk/dapr.yaml create mode 100644 jobs/csharp/sdk/job-scheduler/Program.cs create mode 100644 jobs/csharp/sdk/job-scheduler/jobs-scheduler.csproj create mode 100644 jobs/csharp/sdk/job-service/Program.cs create mode 100644 jobs/csharp/sdk/job-service/Properties/launchSettings.json create mode 100644 jobs/csharp/sdk/job-service/appsettings.Development.json create mode 100644 jobs/csharp/sdk/job-service/appsettings.json create mode 100644 jobs/csharp/sdk/job-service/job-service.csproj create mode 100644 jobs/csharp/sdk/makefile diff --git a/jobs/csharp/sdk/README.md b/jobs/csharp/sdk/README.md new file mode 100644 index 000000000..e866c3fab --- /dev/null +++ b/jobs/csharp/sdk/README.md @@ -0,0 +1,179 @@ +# Dapr Jobs API (SDK) + +In this quickstart, you'll schedule, get, and delete a job using Dapr's Job API. This API is responsible for scheduling and running jobs at a specific time or interval. + +Visit [this](https://docs.dapr.io/developing-applications/building-blocks/jobs/) link for more information about Dapr and the Jobs API. + +> **Note:** This example leverages the Dotnet SDK. If you are looking for the example using only HTTP requests, [click here](../http/). + +This quickstart includes two apps: + +- Jobs Scheduler, responsible for scheduling, retrieving and deleting jobs. +- Jobs Service, responsible for handling the triggered jobs. + +## Run all apps with multi-app run template file + +This section shows how to run both applications at once using [multi-app run template files](https://docs.dapr.io/developing-applications/local-development/multi-app-dapr-run/multi-app-overview/) with `dapr run -f .`. This enables to you test the interactions between multiple applications and will `schedule`, `run`, `get`, and `delete` jobs within a single process. + +1. Build the apps: + + + +```bash +cd ./job-service +dotnet build +``` + + + + + +```bash +cd ./job-scheduler +dotnet build +``` + + + +2. Run the multi app run template: + + + +```bash +dapr run -f . +``` + +The terminal console output should look similar to this, where: + +- The `R2-D2` job is being scheduled. +- The `R2-D2` job is being retrieved. +- The `C-3PO` job is being scheduled. +- The `C-3PO` job is being retrieved. +- The `R2-D2` job is being executed after 15 seconds. +- The `C-3PO` job is being executed after 20 seconds. + +```text +== APP - job-scheduler-sdk == Job Scheduled: R2-D2 +== APP - job-scheduler-sdk == Job details: {"name":"R2-D2", "dueTime":"15s", "data":{"@type":"type.googleapis.com/google.protobuf.Value", "value":{"Value":"R2-D2:Oil Change"}}} +== APP - job-scheduler-sdk == Job Scheduled: C-3PO +== APP - job-scheduler-sdk == Job details: {"name":"C-3PO", "dueTime":"20s", "data":{"@type":"type.googleapis.com/google.protobuf.Value", "value":{"Value":"C-3PO:Limb Calibration"}}} +== APP - job-service-sdk == Received job request... +== APP - job-service-sdk == Starting droid: R2-D2 +== APP - job-service-sdk == Executing maintenance job: Oil Change +``` + +After 20 seconds, the terminal output should present the `C-3PO` job being processed: + +```text +== APP - job-service-sdk == Received job request... +== APP - job-service-sdk == Starting droid: C-3PO +== APP - job-service-sdk == Executing maintenance job: Limb Calibration +``` + + + +## Run apps individually + +### Schedule Jobs + +1. Open a terminal and run the `job-service` app. Build the dependencies if you haven't already. + +```bash +cd ./job-service +dotnet build +``` + +```bash +dapr run --app-id job-service-sdk --app-port 6200 --dapr-http-port 6280 -- dotnet run +``` + +2. In a new terminal window, schedule the `R2-D2` Job using the Jobs API. + +```bash +curl -X POST \ + http://localhost:6200/scheduleJob \ + -H "Content-Type: application/json" \ + -d '{ + "name": "R2-D2", + "job": "Oil Change", + "dueTime": 2 + }' +``` + +In the `job-service` terminal window, the output should be: + +```text +== APP - job-app == Received job request... +== APP - job-app == Starting droid: R2-D2 +== APP - job-app == Executing maintenance job: Oil Change +``` + +3. On the same terminal window, schedule the `C-3PO` Job using the Jobs API. + +```bash +curl -X POST \ + http://localhost:6200/scheduleJob \ + -H "Content-Type: application/json" \ + -d '{ + "name": "C-3PO", + "job": "Limb Calibration", + "dueTime": 30 + }' +``` + +### Get a scheduled job + +1. On the same terminal window, run the command below to get the recently scheduled `C-3PO` job. + +```bash +curl -X GET http://localhost:6200/getJob/C-3PO -H "Content-Type: application/json" +``` + +You should see the following: + +```text +{"name":"c-3po", "dueTime":"30s", "data":{"@type":"type.googleapis.com/google.protobuf.Value", "value":{"Value":"C-3PO:Limb Calibration"}}} +``` + +### Delete a scheduled job + +1. On the same terminal window, run the command below to deleted the recently scheduled `C-3PO` job. + +```bash +curl -X DELETE http://localhost:6200/deleteJob/C-3PO -H "Content-Type: application/json" +``` + +2. Run the command below to attempt to retrieve the deleted job: + +```bash +curl -X GET http://localhost:6200/getJob/C-3PO -H "Content-Type: application/json" +``` + +In the `job-service` terminal window, the output should be similar to the following: + +```text +ERRO[0157] Error getting job C-3PO due to: rpc error: code = NotFound desc = job not found: C-3PO instance=local scope=dapr.api type=log ver=1.15.0 +``` diff --git a/jobs/csharp/sdk/dapr.yaml b/jobs/csharp/sdk/dapr.yaml new file mode 100644 index 000000000..e96c92ba2 --- /dev/null +++ b/jobs/csharp/sdk/dapr.yaml @@ -0,0 +1,16 @@ +version: 1 +apps: + - appDirPath: ./job-service/ + appID: job-service-sdk + appPort: 6200 + daprHTTPPort: 6280 + command: ["dotnet", "run"] + appLogDestination: console + daprdLogDestination: console + - appDirPath: ./job-scheduler/ + appID: job-scheduler-sdk + appPort: 6300 + daprHTTPPort: 6380 + command: ["dotnet", "run"] + appLogDestination: console + daprdLogDestination: console \ No newline at end of file diff --git a/jobs/csharp/sdk/job-scheduler/Program.cs b/jobs/csharp/sdk/job-scheduler/Program.cs new file mode 100644 index 000000000..65c9e64aa --- /dev/null +++ b/jobs/csharp/sdk/job-scheduler/Program.cs @@ -0,0 +1,101 @@ +#pragma warning disable CS0618 // Type or member is obsolete + +using System.Text.Json.Serialization; +using Dapr.Client; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +// Instantiate an HTTP client for invoking the job-service-sdk application +var httpClient = DaprClient.CreateInvokeHttpClient(appId: "job-service-sdk"); + +// Job details +var r2d2Job = new DroidJob +{ + Name = "R2-D2", + Job = "Oil Change", + DueTime = 15 +}; + +var c3poJob = new DroidJob +{ + Name = "C-3PO", + Job = "Limb Calibration", + DueTime = 20 +}; + +await Task.Delay(50); + +try +{ + // Schedule R2-D2 job + await ScheduleJob(r2d2Job); + await Task.Delay(5000); + // Get R2-D2 job details + await GetJobDetails(r2d2Job); + + // Schedule C-3PO job + await ScheduleJob(c3poJob); + await Task.Delay(5000); + // Get C-3PO job details + await GetJobDetails(c3poJob); + + await Task.Delay(30000); // Allow time for jobs to complete +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Error: {ex.Message}"); + Environment.Exit(1); +} + +async Task ScheduleJob(DroidJob job) +{ + Console.WriteLine($"Scheduling job: " + job.Name); + + try + { + var response = await httpClient.PostAsJsonAsync("/scheduleJob", job); + var result = await response.Content.ReadAsStringAsync(); + + response.EnsureSuccessStatusCode(); + Console.WriteLine($"Job scheduled successfully: {job.Name}, {result}"); + } + catch (Exception e) + { + Console.WriteLine($"Error scheduling job: " + e); + } +} + +async Task GetJobDetails(DroidJob job) +{ + Console.WriteLine($"Getting job details: " + job.Name); + + try + { + var response = await httpClient.GetAsync($"/getJob/{job.Name}"); + var jobDetails = await response.Content.ReadAsStringAsync(); + + response.EnsureSuccessStatusCode(); + Console.WriteLine($"Job details: {jobDetails}"); + } + catch (Exception e) + { + Console.WriteLine($"Error getting job: " + e); + } +} + +await app.RunAsync(); + +public class DroidJob +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("job")] + public string? Job { get; set; } + + [JsonPropertyName("dueTime")] + public int DueTime { get; set; } +} + +#pragma warning restore CS0618 // Type or member is obsolete \ No newline at end of file diff --git a/jobs/csharp/sdk/job-scheduler/jobs-scheduler.csproj b/jobs/csharp/sdk/job-scheduler/jobs-scheduler.csproj new file mode 100644 index 000000000..3e3c2723b --- /dev/null +++ b/jobs/csharp/sdk/job-scheduler/jobs-scheduler.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + jobs_scheduler + enable + enable + + + + + + + diff --git a/jobs/csharp/sdk/job-service/Program.cs b/jobs/csharp/sdk/job-service/Program.cs new file mode 100644 index 000000000..e2554d85f --- /dev/null +++ b/jobs/csharp/sdk/job-service/Program.cs @@ -0,0 +1,163 @@ +#pragma warning disable CS0618 // Type or member is obsolete +using Dapr.Jobs; +using Dapr.Jobs.Extensions; +using Dapr.Jobs.Models; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +// The jobs host is a background service that connects to the sidecar over gRPC +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddDaprJobsClient(); +var app = builder.Build(); + +var appPort = Environment.GetEnvironmentVariable("APP_PORT") ?? "6200"; +var jobsClient = app.Services.GetRequiredService(); + +app.MapPost("/scheduleJob", async (HttpContext context) => +{ + Console.WriteLine("Scheduling job..."); + var droidJob = await JsonSerializer.DeserializeAsync(context.Request.Body); + if (droidJob?.Name is null || droidJob?.Job is null) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Job must contain a name and a task " + context.Request.Body); + return; + } + + try { + //Create a Cron expression to run once after schedule defined seconds + var cronBuilder = new CronExpressionBuilder().On(OnCronPeriod.Second, droidJob.DueTime); + Console.WriteLine($"Scheduling job with cron expression: " + cronBuilder); + + var jobData = new JobData { + Droid = droidJob.Name, + Task = droidJob.Job + }; + + //await jobsClient.ScheduleJobAsync(droidJob.Name, DaprJobSchedule.FromDateTime(DateTime.UtcNow.AddSeconds(droidJob.DueTime)), JsonSerializer.SerializeToUtf8Bytes(jobData), DateTime.UtcNow, 1); + await jobsClient.ScheduleJobWithPayloadAsync(droidJob.Name, DaprJobSchedule.FromCronExpression(cronBuilder), jobData, DateTime.UtcNow, 1); //Schedule cron job that repeats once + Console.WriteLine($"Job Scheduled: {droidJob.Name}"); + + context.Response.StatusCode = 200; + await context.Response.WriteAsJsonAsync(droidJob); + + } catch (Exception e) { + Console.WriteLine($"Error scheduling job: " + e); + } + return; +}); + +app.MapGet("/getJob/{name}", async (HttpContext context) => +{ + var jobName = context.Request.RouteValues["name"]?.ToString(); + Console.WriteLine($"Getting job: " + jobName); + + if (string.IsNullOrEmpty(jobName)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Job name required"); + return; + } + + try { + // Error here: ---> System.FormatException: String '' was not recognized as a valid DateTime. + var jobDetails = await jobsClient.GetJobAsync(jobName); + Console.WriteLine($"Job schedule: {jobDetails?.Schedule}"); + Console.WriteLine($"Job payload: {jobDetails?.Payload}"); + + context.Response.StatusCode = 200; + await context.Response.WriteAsJsonAsync(jobDetails); + + } catch (Exception e) { + Console.WriteLine($"Error getting job: " + e); + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Error getting job"); + } + return; +}); + +app.MapDelete("/deleteJob/{name}", async (HttpContext context) => +{ + var jobName = context.Request.RouteValues["name"]?.ToString(); + Console.WriteLine($"Deleting job: " + jobName); + + if (string.IsNullOrEmpty(jobName)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Job name required"); + return; + } + + try { + await jobsClient.DeleteJobAsync(jobName); + Console.WriteLine($"Job deleted: {jobName}"); + + context.Response.StatusCode = 200; + await context.Response.WriteAsync("Job deleted"); + + } catch (Exception e) { + Console.WriteLine($"Error deleting job: " + e); + context.Response.StatusCode = 400; + await context.Response.WriteAsync($"Error deleting job"); + } + return; +}); + +//Job handler route to capture incoming jobs +app.MapDaprScheduledJobHandler((string jobName, ReadOnlyMemory jobPayload) => +{ + Console.WriteLine($"Received trigger invocation for job name: {jobName}"); + var deserializedPayload = Encoding.UTF8.GetString(jobPayload.Span); + + try + { + if (deserializedPayload is null){ + throw new Exception("Payload is null"); + } + + var jobData = JsonSerializer.Deserialize(deserializedPayload); + Console.WriteLine($"Received invocation for the job {jobName} with job data droid {jobData?.Droid}"); + + if (jobData?.Droid is null || jobData?.Task is null) + { + throw new Exception("Invalid format of job data."); + } + + // Handling Droid Job from decoded value + Console.WriteLine($"Starting droid: {jobData.Droid}"); + Console.WriteLine($"Executing maintenance job: {jobData.Task}"); + } catch (Exception ex) + { + Console.WriteLine($"Failed to handle job {jobName}"); + Console.Error.WriteLine($"Error handling job: {ex.Message}"); + } + return Task.CompletedTask; +}); + +app.UseRouting(); +app.Run($"http://*:{appPort}"); + +// Classes for request and response models +public class JobData +{ + [JsonPropertyName("droid")] + public string? Droid { get; set; } + + [JsonPropertyName("task")] + public string? Task { get; set; } +} + +public class DroidJob +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("job")] + public string? Job { get; set; } + + [JsonPropertyName("dueTime")] + public int DueTime { get; set; } +} + +#pragma warning restore CS0618 // Type or member is obsolete \ No newline at end of file diff --git a/jobs/csharp/sdk/job-service/Properties/launchSettings.json b/jobs/csharp/sdk/job-service/Properties/launchSettings.json new file mode 100644 index 000000000..2b151427e --- /dev/null +++ b/jobs/csharp/sdk/job-service/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5305", + "sslPort": 44346 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5023", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7073;http://localhost:5023", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/csharp/sdk/job-service/appsettings.Development.json b/jobs/csharp/sdk/job-service/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/jobs/csharp/sdk/job-service/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/csharp/sdk/job-service/appsettings.json b/jobs/csharp/sdk/job-service/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/jobs/csharp/sdk/job-service/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/jobs/csharp/sdk/job-service/job-service.csproj b/jobs/csharp/sdk/job-service/job-service.csproj new file mode 100644 index 000000000..261473357 --- /dev/null +++ b/jobs/csharp/sdk/job-service/job-service.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/jobs/csharp/sdk/makefile b/jobs/csharp/sdk/makefile new file mode 100644 index 000000000..e7a8826bf --- /dev/null +++ b/jobs/csharp/sdk/makefile @@ -0,0 +1,2 @@ +include ../../../docker.mk +include ../../../validate.mk \ No newline at end of file From 2b272ee40adafc1863a9e68396cffe475ea0b773 Mon Sep 17 00:00:00 2001 From: Fernando Rocha Date: Wed, 26 Feb 2025 12:33:08 -0800 Subject: [PATCH 2/5] updating SDK version Signed-off-by: Fernando Rocha --- jobs/csharp/sdk/README.md | 8 ++++---- jobs/csharp/sdk/job-scheduler/jobs-scheduler.csproj | 3 ++- jobs/csharp/sdk/job-service/job-service.csproj | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/jobs/csharp/sdk/README.md b/jobs/csharp/sdk/README.md index e866c3fab..7333437b4 100644 --- a/jobs/csharp/sdk/README.md +++ b/jobs/csharp/sdk/README.md @@ -46,8 +46,8 @@ dotnet build System.FormatException: String '' was not recognized as a valid DateTime. + try + { var jobDetails = await jobsClient.GetJobAsync(jobName); - Console.WriteLine($"Job schedule: {jobDetails?.Schedule}"); - Console.WriteLine($"Job payload: {jobDetails?.Payload}"); context.Response.StatusCode = 200; await context.Response.WriteAsJsonAsync(jobDetails); - } catch (Exception e) { + } + catch (Exception e) + { Console.WriteLine($"Error getting job: " + e); context.Response.StatusCode = 400; await context.Response.WriteAsync($"Error getting job"); @@ -76,7 +78,7 @@ { var jobName = context.Request.RouteValues["name"]?.ToString(); Console.WriteLine($"Deleting job: " + jobName); - + if (string.IsNullOrEmpty(jobName)) { context.Response.StatusCode = 400; @@ -84,14 +86,17 @@ return; } - try { + try + { await jobsClient.DeleteJobAsync(jobName); Console.WriteLine($"Job deleted: {jobName}"); context.Response.StatusCode = 200; await context.Response.WriteAsync("Job deleted"); - } catch (Exception e) { + } + catch (Exception e) + { Console.WriteLine($"Error deleting job: " + e); context.Response.StatusCode = 400; await context.Response.WriteAsync($"Error deleting job"); @@ -99,21 +104,20 @@ return; }); -//Job handler route to capture incoming jobs +// Job handler route to capture incoming jobs app.MapDaprScheduledJobHandler((string jobName, ReadOnlyMemory jobPayload) => { - Console.WriteLine($"Received trigger invocation for job name: {jobName}"); + Console.WriteLine("Handling job..."); var deserializedPayload = Encoding.UTF8.GetString(jobPayload.Span); try { - if (deserializedPayload is null){ + if (deserializedPayload is null) + { throw new Exception("Payload is null"); } var jobData = JsonSerializer.Deserialize(deserializedPayload); - Console.WriteLine($"Received invocation for the job {jobName} with job data droid {jobData?.Droid}"); - if (jobData?.Droid is null || jobData?.Task is null) { throw new Exception("Invalid format of job data."); @@ -122,7 +126,8 @@ // Handling Droid Job from decoded value Console.WriteLine($"Starting droid: {jobData.Droid}"); Console.WriteLine($"Executing maintenance job: {jobData.Task}"); - } catch (Exception ex) + } + catch (Exception ex) { Console.WriteLine($"Failed to handle job {jobName}"); Console.Error.WriteLine($"Error handling job: {ex.Message}"); @@ -150,7 +155,7 @@ public class DroidJob [JsonPropertyName("job")] public string? Job { get; set; } - + [JsonPropertyName("dueTime")] public int DueTime { get; set; } }