diff --git a/jobs/csharp/sdk/README.md b/jobs/csharp/sdk/README.md new file mode 100644 index 000000000..5a2d635c2 --- /dev/null +++ b/jobs/csharp/sdk/README.md @@ -0,0 +1,185 @@ +# 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 == Scheduling job... +== APP - job-service-sdk == Job Scheduled: R2-D2 +== APP - job-scheduler-sdk == Job scheduled: {"name":"R2-D2","job":"Oil Change","dueTime":15} +== APP - job-scheduler-sdk == Getting job: R2-D2 +== APP - job-service-sdk == Getting job... +== APP - job-scheduler-sdk == Job details: {"schedule":"@every 15s","repeatCount":1,"dueTime":null,"ttl":null,"payload":"ChtkYXByLmlvL3NjaGVkdWxlL2pvYnBheWxvYWQSJXsiZHJvaWQiOiJSMi1EMiIsInRhc2siOiJPaWwgQ2hhbmdlIn0="} +== APP - job-scheduler-sdk == Scheduling job... +== APP - job-service-sdk == Job Scheduled: C-3PO +== APP - job-scheduler-sdk == Job scheduled: {"name":"C-3PO","job":"Limb Calibration","dueTime":20} +== APP - job-scheduler-sdk == Getting job: C-3PO +== APP - job-service-sdk == Getting job... +== APP - job-scheduler-sdk == Job details: {"schedule":"@every 20s","repeatCount":1,"dueTime":null,"ttl":null,"payload":"ChtkYXByLmlvL3NjaGVkdWxlL2pvYnBheWxvYWQSK3siZHJvaWQiOiJDLTNQTyIsInRhc2siOiJMaW1iIENhbGlicmF0aW9uIn0="} +== APP - job-service-sdk == Handling job... +== 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 == Handling job... +== 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..79df5842d --- /dev/null +++ b/jobs/csharp/sdk/job-scheduler/Program.cs @@ -0,0 +1,103 @@ +#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(); + +await Task.Delay(5000); // Allow time for the job-service-sdk to start + +// 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..."); + + try + { + var response = await httpClient.PostAsJsonAsync("/scheduleJob", job); + var result = await response.Content.ReadAsStringAsync(); + + response.EnsureSuccessStatusCode(); + Console.WriteLine($"Job scheduled: {result}"); + } + catch (Exception e) + { + Console.WriteLine($"Error scheduling job: " + e); + } +} + +async Task GetJobDetails(DroidJob job) +{ + Console.WriteLine($"Getting job: " + 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..3bfa2adc2 --- /dev/null +++ b/jobs/csharp/sdk/job-scheduler/jobs-scheduler.csproj @@ -0,0 +1,16 @@ + + + + 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..82301d5d8 --- /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) => +{ + 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 + { + var jobData = new JobData + { + Droid = droidJob.Name, + Task = droidJob.Job + }; + + await jobsClient.ScheduleJobWithPayloadAsync(droidJob.Name, DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(droidJob.DueTime)), payload: jobData, repeats: 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..."); + + if (string.IsNullOrEmpty(jobName)) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Job name required"); + return; + } + + try + { + var jobDetails = await jobsClient.GetJobAsync(jobName); + + 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("Handling job..."); + var deserializedPayload = Encoding.UTF8.GetString(jobPayload.Span); + + try + { + if (deserializedPayload is null) + { + throw new Exception("Payload is null"); + } + + var jobData = JsonSerializer.Deserialize(deserializedPayload); + 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..77287559f --- /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