├── .deployment
├── .github
└── workflows
│ └── azure-functions-app-dotnet.yml
├── .gitignore
├── LICENSE
├── README.md
├── deploy
├── _vars.ps1
├── conflict-policy.json
├── deploy-azure.ps1
├── deploy-function.ps1
└── deploy-sprocs.ps1
├── docs
└── conflict-feed.md
├── http.http
├── sprocs
└── incrementNumber.js
├── src
└── NumberService
│ ├── ConflictResult.cs
│ ├── CosmosDiagnostics.cs
│ ├── GetConflicts.cs
│ ├── GetNumber.cs
│ ├── NumberResult.cs
│ ├── NumberService.csproj
│ ├── NumberService.sln
│ ├── Properties
│ └── serviceDependencies.json
│ ├── PutNumber.cs
│ ├── Startup.cs
│ ├── TelemetryClientCosmosExtensions.cs
│ └── host.json
└── test
├── test-multiregion.ps1
└── test.ps1
/.deployment:
--------------------------------------------------------------------------------
1 | [config]
2 | project = src/NumberService
3 |
--------------------------------------------------------------------------------
/.github/workflows/azure-functions-app-dotnet.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a .NET Core project and deploy it to an Azure Functions App on Windows or Linux when a commit is pushed to your default branch.
2 | #
3 | # This workflow assumes you have already created the target Azure Functions app.
4 | # For instructions see https://learn.microsoft.com/en-us/azure/azure-functions/create-first-function-vs-code-csharp?tabs=in-process
5 | #
6 | # To configure this workflow:
7 | # 1. Set up the following secrets in your repository:
8 | # - AZURE_FUNCTIONAPP_PUBLISH_PROFILE
9 | # 2. Change env variables for your configuration.
10 | #
11 | # For more information on:
12 | # - GitHub Actions for Azure: https://github.com/Azure/Actions
13 | # - Azure Functions Action: https://github.com/Azure/functions-action
14 | # - Publish Profile: https://github.com/Azure/functions-action#using-publish-profile-as-deployment-credential-recommended
15 | # - Azure Service Principal for RBAC: https://github.com/Azure/functions-action#using-azure-service-principal-for-rbac-as-deployment-credential
16 | #
17 | # For more samples to get started with GitHub Action workflows to deploy to Azure: https://github.com/Azure/actions-workflow-samples/tree/master/FunctionApp
18 |
19 | name: Deploy DotNet project to Azure Function App
20 |
21 | on:
22 | push:
23 | branches: ["main"]
24 | paths: ["src/**", ".github/workflows/**"]
25 |
26 | workflow_dispatch:
27 |
28 | env:
29 | AZURE_FUNCTIONAPP_NAME: 'numberservice-aue' # set this to your function app #1 name on Azure
30 | AZURE_FUNCTIONAPP_NAME2: 'numberservice-ase' # set this to your function app #2 name on Azure
31 | AZURE_FUNCTIONAPP_PACKAGE_PATH: './src/NumberService' # set this to the path to your function app project, defaults to the repository root
32 | DOTNET_VERSION: '6.0.x' # set this to the dotnet version to use (e.g. '2.1.x', '3.1.x', '5.0.x')
33 |
34 | jobs:
35 | build-and-deploy:
36 | runs-on: windows-latest # For Linux, use ubuntu-latest
37 | environment: dev
38 | steps:
39 | - name: 'git checkout'
40 | uses: actions/checkout@v3
41 |
42 | - name: 'az login'
43 | uses: azure/login@v1
44 | with:
45 | creds: ${{ secrets.AZURE_RBAC_CREDENTIALS }}
46 |
47 | - name: dotnet install ${{ env.DOTNET_VERSION }}
48 | uses: actions/setup-dotnet@v3
49 | with:
50 | dotnet-version: ${{ env.DOTNET_VERSION }}
51 |
52 | - name: 'dotnet publish'
53 | shell: pwsh # For Linux, use bash
54 | run: |
55 | pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}'
56 | dotnet build --configuration Release --output ./output
57 | popd
58 |
59 | - name: 'deploy function 1'
60 | uses: Azure/functions-action@v1
61 | id: fa1
62 | with:
63 | app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }}
64 | package: '${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}/output'
65 |
66 | - name: 'deploy function 2'
67 | uses: Azure/functions-action@v1
68 | id: fa2
69 | with:
70 | app-name: ${{ env.AZURE_FUNCTIONAPP_NAME2 }}
71 | package: '${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}/output'
72 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vs
2 | bin
3 | obj
4 | *.user
5 | *.suo
6 | local.settings.json
7 | launchSettings.json
8 | serviceDependencies.local.json
9 | serviceDependencies.local.json.user
10 | _work
11 | PublishProfiles
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Daniel Larsen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Number Service
2 |
3 | Highly available sequential number generator on Azure Functions backed by Cosmos DB with guaranteed uniqueness*.
4 |
5 | * .NET 6
6 | * Functions v4
7 | * Deployment scripts and GitHub Action workflow
8 | * Application insights integration
9 | * Supports multi-region deployments for high availability, with conflict resolution.
10 |
11 | [Free numbers here!](https://numberservice-aue.azurewebsites.net/api/numbers/free)
12 |
13 | > ⚠️ This service is provided for demonstration purposes only and may go offline or reset at any time.
14 |
15 | PUT https://numberservice-aue.azurewebsites.net/api/numbers/free
16 | GET https://numberservice-aue.azurewebsites.net/api/numbers/free
17 |
18 | PUT https://numberservice-ase.azurewebsites.net/api/numbers/free
19 | GET https://numberservice-ase.azurewebsites.net/api/numbers/free
20 |
21 | * _Within same region. For multiple regions, conflict resolution is required._
22 |
23 | ## Getting started
24 |
25 | Fork this repo and then clone:
26 |
27 | ```bash
28 | git clone https://github.com/(your_user_or_org_name)/NumberService
29 | cd NumberService
30 | ```
31 |
32 | * Edit the constant variables in `deploy/_vars.ps1`. Make changes where required.
33 |
34 | Now deploy the Azure resources.
35 |
36 | > `deploy-azure.ps1` will create a service principal and echo out the RBAC credentials as JSON. Copy the JSON text and paste into a GitHub Actions repository secret, as described below.
37 |
38 | ```bash
39 | cd deploy
40 | az login
41 | ./deploy-azure.ps1
42 | ./deploy-sprocs.ps1
43 | ./deploy-function.ps1 -FunctionLocation "australiaeast"
44 | ./deploy-function.ps1 -FunctionLocation "australiasoutheast"
45 | ```
46 |
47 | ### GitHub Action
48 |
49 | The GitHub Action requires a repo secret named `AZURE_RBAC_CREDENTIALS`. Copy this value from the output of `deploy-azure.ps1` and paste into the secret value. Note that the service principal is scoped to the Resource Group (with contributor access) and not the individual web apps. For more information, see [Using Azure Service Principal for RBAC as Deployment Credential](https://github.com/marketplace/actions/azure-functions-action#using-azure-service-principal-for-rbac-as-deployment-credential).
50 |
51 | * Now run the GitHub Action to publish the source code to the Functions.
52 |
53 |
54 | ## Requirements
55 |
56 | * Each request for a new number must return a number that is unique and one greater than the last number generated (for all clients).
57 | * Number should be able to be seeded (start at 10,000 for example), but only the first time it is generated
58 | * Number service must be highly available with an uptime of around 99.99%, or less than 5 minutes of total downtime per month.
59 | * RTO = minutes
60 | * RPO = 0
61 |
62 | ## Phase 1
63 |
64 | Number generation requires a strong write. It is not possible to have a strong write and multiple write regions, even with a consensus algorithm (not even Cosmos DB offers strong multi-region writes).
65 |
66 | The Google **Chubby lock service** is actually a single write region service. It orchestrates a master1. The read replicas (which may be thousands of miles away) are mirrors and are eventually consistent. This is OK because chubby is essentially a consistent cache lock service.
67 |
68 | Phase 1 fulfils the sequential guarantee requirement with Azure Cosmos DB and Functions. A Cosmos DB stored procedure is used to read a lock document, increment the number, and replacement (with an etag pre-condition) in one procedure. If the proc is unable to replace the document (due another client updating first) then the proc will throw an exception.
69 |
70 | [Stored procedures and triggers are always executed on the primary replica of an Azure Cosmos container](https://docs.microsoft.com/en-us/azure/cosmos-db/stored-procedures-triggers-udfs#data-consistency#:~:text=Stored%20procedures%20and%20triggers%20are%20always%20executed%20on%20the%20primary%20replica%20of%20an%20Azure%20Cosmos%20container). This guarantees strong consistency within the proc (regardless of container consistency level) and is perfect for this use case. It is also interesting to note that Cosmos will ensure a [local majority quorum write before acknowledging back to the client](https://docs.microsoft.com/en-us/azure/cosmos-db/consistency-levels-tradeoffs#consistency-levels-and-throughput) (regardless of container consistency level). Strong consistency and multi-region reads ensures global majority quorum writes.
71 |
72 | The phase one cost is ~NZ$75 per month for 28 numbers per second.
73 |
74 | ### Analysis
75 |
76 | * 1 Cosmos DB Region. Zone Redundant.
77 | * 1 Azure Functions (consumption) region
78 | * 99.94% uptime. No region failover
79 | * RPO and RTO for Zone failure = 0
80 | * RPO for Region failure could be hours and RTO for Region failure could be days, assuming the Region never recovers. However an RPO of 0 and an RTO of hours is more likely IMO.
81 | * ~14 RU/s per number
82 | * Single partition (per key)
83 | * Max RU/s per partition = 10,000, so max throughput is 625 per second
84 | * At 400 RU/s provision, max throughput is 28 per second.
85 | * Highest number currently supported (I assume) is Javascript `Number.MAX_SAFE_INTEGER`, which is 9,007,199,254,740,991.
86 | * Stored proc write consistency is strong. If proc can't increment number in atomic operation it will fail with an exception that is thrown to client.
87 | * Read consistency is the default (session). While out of sproc/session reads may not get the latest number, ordering will be consistent. Strong consistency of reads is not a requirement for NumberService.
88 |
89 | ### Costs
90 |
91 | > 🧮 [Azure Pricing Calculator estimate](https://azure.com/e/cfb40099955e4f83bdfe059840ece9dd)
92 |
93 | * Cosmos DB, single region (Australia East), ZR, 400 RU/s, 1GB data = NZ$51.21 /month
94 | * Azure Functions, Consumption, Australia East, @28RPS = NZ$23.90 /month
95 |
96 | ## Phase 2
97 |
98 | In this phase we are experimenting with multi-region writes and conflict resolution in Cosmos DB.
99 |
100 | GET https://numberservice-aue.azurewebsites.net/api/conflicts/free
101 | GET https://numberservice-ase.azurewebsites.net/api/conflicts/free
102 |
103 | Run the test script `test/test-multiregion.ps1` to (eventually) observe a conflict due to multi-region writes. This can take some time; synchronization between regional replicas is super fast!
104 |
105 | ### Analysis
106 |
107 | * 2 Cosmos DB Regions, multi-region writes enabled
108 | * 2 Azure Functions (consumption), one in each region.
109 | * ~24 RU/s per number
110 | * Single partition (per key)
111 | * As long as clients only PUT to one region, consistency is strong. If multi-region writes, conflict resolution is required.
112 |
113 | ## References and links
114 |
115 | 1 [The Chubby lock service for loosely-coupled distributed systems](https://research.google.com/archive/chubby-osdi06.pdf), section 2.12.
116 |
117 | Read from conflict feed:
118 |
119 | Use the `ApplicationPreferredRegions` property to set the preferred region:
120 |
--------------------------------------------------------------------------------
/deploy/_vars.ps1:
--------------------------------------------------------------------------------
1 | $location = 'australiaeast'
2 | $loc = 'aue'
3 | $rg = 'numberservice-rg'
4 | $tags = 'project=NumberService', 'repo=DanielLarsenNZ/NumberService'
5 | $insights = 'numberservice-insights'
6 | $cosmos = 'numberservice4'
7 | $cosmosDB = 'NumberService'
8 | $container = 'Numbers'
9 |
--------------------------------------------------------------------------------
/deploy/conflict-policy.json:
--------------------------------------------------------------------------------
1 | { "mode": "custom" }
--------------------------------------------------------------------------------
/deploy/deploy-azure.ps1:
--------------------------------------------------------------------------------
1 | $DeployCosmos = $true
2 |
3 | # Deploy NumberService resources
4 | $ErrorActionPreference = 'Stop'
5 | . ./_vars.ps1
6 |
7 | $throughput = 400
8 | $pk = '/id'
9 | $primaryCosmosLocation = 'australiaeast'
10 | $secondaryCosmosLocation = 'australiasoutheast'
11 |
12 |
13 | # RESOURCE GROUP
14 | $rgId = ( az group create -n $rg --location $location --tags $tags | ConvertFrom-Json ).id
15 | $rgId
16 |
17 |
18 | Write-Host "Copy the RBAC JSON below and paste into a GitHub Action Repository secret named AZURE_RBAC_CREDENTIALS."
19 | # Note that this service principal is scoped to the resource group with Contributor access
20 | az ad sp create-for-rbac --name "$rg-sp" --role contributor --scopes $rgId --sdk-auth
21 |
22 |
23 | if ($DeployCosmos) {
24 | # COSMOS DB ACCOUNT
25 | az cosmosdb create -n $cosmos -g $rg --default-consistency-level Session `
26 | --locations regionName=$primaryCosmosLocation failoverPriority=0 isZoneRedundant=True `
27 | --locations regionName=$secondaryCosmosLocation failoverPriority=1 isZoneRedundant=False `
28 | --enable-automatic-failover $true `
29 | --enable-multiple-write-locations $true
30 | az cosmosdb sql database create -a $cosmos -g $rg -n $cosmosDB --throughput $throughput
31 | az cosmosdb sql container create -a $cosmos -g $rg -d $cosmosDB -n $container -p $pk --conflict-resolution-policy @conflict-policy.json
32 | }
33 |
--------------------------------------------------------------------------------
/deploy/deploy-function.ps1:
--------------------------------------------------------------------------------
1 | [CmdletBinding()]
2 | param (
3 | # TODO: Add more regions here
4 | [Parameter(Mandatory = $true)] [ValidateSet('australiaeast', 'australiasoutheast')] $FunctionLocation
5 | )
6 |
7 | $ErrorActionPreference = 'Stop'
8 |
9 | . ./_vars.ps1
10 |
11 | # TODO: Add more regions here
12 | $loc = switch ($FunctionLocation) {
13 | 'australiaeast' { 'aue' }
14 | 'australiasoutheast' { 'ase' }
15 | default { throw "$FunctionLocation is not supported" }
16 | }
17 |
18 | $functionApp = "numberservice-$loc"
19 | $storage = "numservfn$loc"
20 |
21 | # STORAGE ACCOUNT
22 | az storage account create -n $storage -g $rg --tags $tags --location $FunctionLocation --sku 'Standard_LRS'
23 |
24 | # Get cosmos and application insights keys
25 | $cosmosConnString = ( az cosmosdb keys list -n $cosmos -g $rg --type 'connection-strings' | ConvertFrom-Json ).connectionStrings[0].connectionString
26 | $insightsKey = ( az monitor app-insights component show -a $insights -g $rg | ConvertFrom-Json ).instrumentationKey
27 |
28 |
29 | # FUNCTION APP
30 | az functionapp create -n $functionApp -g $rg --consumption-plan-location $FunctionLocation --functions-version 4 `
31 | --app-insights $insights --app-insights-key $insightsKey -s $storage
32 | az functionapp config appsettings set -n $functionApp -g $rg --settings `
33 | "CosmosDbConnectionString=$cosmosConnString" `
34 | "CosmosDbDatabaseId=$cosmosDB" `
35 | "CosmosDbContainerId=$container" `
36 | "CosmosApplicationPreferredRegions=$FunctionLocation"
37 |
--------------------------------------------------------------------------------
/deploy/deploy-sprocs.ps1:
--------------------------------------------------------------------------------
1 | # Deploys any stored procedures found in /sprocs
2 | . ./_vars.ps1
3 |
4 | $ErrorActionPreference = 'Stop'
5 |
6 | Install-Module -Name CosmosDB -Confirm # Thanks @PlagueHO !
7 |
8 | $cosmosKey = ( az cosmosdb keys list -n $cosmos -g $rg --type 'keys' | ConvertFrom-Json ).primaryMasterKey
9 | $primaryKey = ConvertTo-SecureString -String $cosmosKey -AsPlainText -Force
10 | $cosmosDbContext = New-CosmosDbContext -Account $cosmos -Database $cosmosdb -Key $primaryKey
11 |
12 | # Get a list of stored procedures in the Cosmos DB container
13 | $sprocs = Get-CosmosDbStoredProcedure -Context $cosmosDbContext -CollectionId $container | % { $_.Id }
14 |
15 | # For each sproc found in /sprocs folder
16 | Get-ChildItem -Path ../sprocs -Name | ForEach-Object {
17 | [string] $body = Get-Content -Path $( Join-Path '../sprocs' $_ ) -Raw
18 |
19 | # Derive id from filename
20 | $id = $_.Replace('.js', '')
21 |
22 | # If sproc exists, update it, otherwise create it
23 | if ($sprocs -contains $id) {
24 | Set-CosmosDbStoredProcedure -Context $cosmosDbContext -CollectionId $container `
25 | -Id $id -StoredProcedureBody $body
26 | } else {
27 | New-CosmosDbStoredProcedure -Context $cosmosDbContext -CollectionId $container `
28 | -Id $id -StoredProcedureBody $body
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/docs/conflict-feed.md:
--------------------------------------------------------------------------------
1 | # Understanding Azure Cosmos DB Conflict Feed
2 |
3 | > 👷🏻♂️ Work in Progress 👷🏻♀️
4 |
5 | Azure Cosmos DB is an amazing _globally_ replicated Database with multi-region write capability, i.e. you can write to any region 🤯. With global replication comes _eventual consistency_. And with multi-region writes comes the requirement to manage Conflicts when the same document is created, updated or deleted at the same time in two different regions. Cosmos DB provides an important tool for developers to manage Conflicts called the _Conflicts Feed_.
6 |
7 | This article explains with demonstrations how Conflicts are handled by Cosmos DB and how the Conflict feed works.
8 |
9 | ## Conflicts are hard
10 |
11 | The first challenge when trying to understand how conflicts are handled by Cosmos is to actually create a conflict. Cosmos is enabled by one of the largest and fastest private networks on the planet. Replication packets flow between regional replicas on dark Microsoft fibre and replication between Australia East and Australia Southeast (in my example) is incredibly quick.
12 |
13 | My parallelised test from Auckland can take more than 100 attempts before I get a collision. Just goes to show how awesome the multi-region sync actually is.
14 |
15 | You can run this yourself on your local or in cloud shell using this script: NumberService/test-multiregion.ps1 at main · DanielLarsenNZ/NumberService (github.com)
16 |
17 | It hits two Functions in parallel, deployed in Australia East and Australia Southeast respectively. They have been configured to only write to their local node. Here’s example output:
18 |
19 |
20 |
21 | I have not configured a Conflict resolution policy yet. The conflicts in Data Explorer is empty:
22 |
23 |
24 |
25 |
26 | When I go to set a custom conflict policy it is not supported:
27 |
28 |
29 | I have to recreate the collection as per https://stackoverflow.com/a/61198147/610731 🤔
30 |
31 | So I did that. Now I have custom conflict resolution, no merge sproc.
32 |
33 |
34 |
35 | After 244 tests I get a conflict on number 488. I only have one current item.
36 |
37 |
38 |
39 | If I check the Conflicts I get:
40 |
41 |
42 |
43 | Very nice! Now I can inform my client “a55a8…” that their number is no longer valid.
44 |
45 |
46 |
47 |
48 | ⭐ My unanswered question is: Is Cosmos in a consistent state; i.e. would it be possible to failover in this state? I have multi-region writes enabled so this is not possible to test?
49 |
50 | Next from a client perspective I would (optionally) Container.ReplaceItemAsync (manual merge) and then Conflicts.DeleteItemAsync.
51 |
52 | For the numbers scenario, I would only ever Delete the conflict, once my client has been notified.
53 |
54 |
55 | ## Notes
56 |
57 | When you turn multi-region writes again, your clients will remain "sticky" to the primary write region at the time. I assume they reconnect after a period of time, or next time the app is restarted.
--------------------------------------------------------------------------------
/http.http:
--------------------------------------------------------------------------------
1 | ### PUT local
2 | PUT http://localhost:7071/api/numbers/foo
3 |
4 | ### GET local
5 | GET http://localhost:7071/api/numbers/foo
6 |
7 | ### GET Conflicts local
8 | GET http://localhost:7071/api/conflicts/foo
9 |
10 | ### GET local
11 | GET http://localhost:7071/
12 |
13 |
14 | ### PUT prod AUE
15 | PUT https://numberservice-aue.azurewebsites.net/api/numbers/free?diagnostics
16 |
17 | ### GET prod AUE
18 | GET https://numberservice-aue.azurewebsites.net/api/numbers/free
19 |
20 | ### PUT prod ASE
21 | PUT https://numberservice-ase.azurewebsites.net/api/numbers/free?diagnostics
22 |
23 | ### GET prod ASE
24 | GET https://numberservice-ase.azurewebsites.net/api/numbers/free
25 |
26 | ### GET Conflicts AUE
27 | GET https://numberservice-aue.azurewebsites.net/api/conflicts/free
28 |
29 | ### GET Conflicts ASE
30 | GET https://numberservice-ase.azurewebsites.net/api/conflicts/free
31 |
--------------------------------------------------------------------------------
/sprocs/incrementNumber.js:
--------------------------------------------------------------------------------
1 | function incrementNumber(key, clientId) {
2 | const asyncHelper = {
3 | readDocument(key) {
4 | return new Promise((resolve, reject) => {
5 | var docLink = __.getAltLink() + "/docs/" + key;
6 |
7 | const isAccepted = __.readDocument(docLink, {}, (err, resource, options) => {
8 | if (err) resolve(null);
9 | resolve(resource);
10 | });
11 |
12 | if (!isAccepted) reject(new Error(ERROR_CODE.NotAccepted, "readDocument was not accepted."));
13 | });
14 | },
15 |
16 | createDocument(key, number, clientId) {
17 | return new Promise((resolve, reject) => {
18 | var docLink = __.getAltLink() + "/docs/" + key;
19 |
20 | const isAccepted = __.createDocument(
21 | __.getSelfLink(),
22 | { id: key, number: number, clientId: clientId },
23 | {},
24 | (err, resource, options) => {
25 | if (err) reject(err);
26 | resolve(resource);
27 | });
28 |
29 | if (!isAccepted) reject(new Error(ERROR_CODE.NotAccepted, "createDocument was not accepted."));
30 | });
31 | },
32 |
33 | replaceDocument(doc) {
34 | return new Promise((resolve, reject) => {
35 | const isAccepted = __.replaceDocument(
36 | doc._self,
37 | doc,
38 | { etag: doc._etag },
39 | (err, result, options) => {
40 | if (err) reject(err);
41 | resolve(result);
42 | });
43 | if (!isAccepted) reject(new Error(ERROR_CODE.NotAccepted, "replaceDocument was not accepted."));
44 | });
45 | }
46 | };
47 |
48 | async function main(key, clientId) {
49 | let doc = await asyncHelper.readDocument(key);
50 |
51 | if (!doc) {
52 | console.log("doc not found, creating doc with key = ", key);
53 | doc = await asyncHelper.createDocument(key, 0, clientId);
54 | }
55 |
56 | doc.number++;
57 | doc.clientId = clientId;
58 |
59 | var newDoc = await asyncHelper.replaceDocument(doc);
60 | getContext().getResponse().setBody(newDoc);
61 | }
62 |
63 | main(key, clientId).catch(err => getContext().abort(err));
64 | }
--------------------------------------------------------------------------------
/src/NumberService/ConflictResult.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace NumberService
6 | {
7 | public class ConflictResult
8 | {
9 | public NumberResult Current { get; set; }
10 | public NumberResult Conflict { get; set; }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/NumberService/CosmosDiagnostics.cs:
--------------------------------------------------------------------------------
1 | // Generated by https://quicktype.io
2 |
3 | namespace NumberService
4 | {
5 | using Newtonsoft.Json;
6 | using System;
7 |
8 | public partial class CosmosDiagnostics
9 | {
10 | [JsonProperty("DiagnosticVersion")]
11 | public long DiagnosticVersion { get; set; }
12 |
13 | [JsonProperty("Summary")]
14 | public Summary Summary { get; set; }
15 |
16 | [JsonProperty("Context")]
17 | public Context[] Context { get; set; }
18 | }
19 |
20 | public partial class Context
21 | {
22 | [JsonProperty("Id")]
23 | public string Id { get; set; }
24 |
25 | [JsonProperty("HandlerElapsedTimeInMs", NullValueHandling = NullValueHandling.Ignore)]
26 | public double? HandlerElapsedTimeInMs { get; set; }
27 |
28 | [JsonProperty("CpuHistory", NullValueHandling = NullValueHandling.Ignore)]
29 | public string CpuHistory { get; set; }
30 |
31 | [JsonProperty("ContactedReplicas", NullValueHandling = NullValueHandling.Ignore)]
32 | public ContactedReplica[] ContactedReplicas { get; set; }
33 |
34 | [JsonProperty("RegionsContacted", NullValueHandling = NullValueHandling.Ignore)]
35 | public Uri[] RegionsContacted { get; set; }
36 |
37 | [JsonProperty("FailedReplicas", NullValueHandling = NullValueHandling.Ignore)]
38 | public object[] FailedReplicas { get; set; }
39 |
40 | [JsonProperty("ElapsedTimeInMs", NullValueHandling = NullValueHandling.Ignore)]
41 | public double? ElapsedTimeInMs { get; set; }
42 |
43 | [JsonProperty("StartTimeUtc", NullValueHandling = NullValueHandling.Ignore)]
44 | public DateTimeOffset? StartTimeUtc { get; set; }
45 |
46 | [JsonProperty("EndTimeUtc", NullValueHandling = NullValueHandling.Ignore)]
47 | public DateTimeOffset? EndTimeUtc { get; set; }
48 |
49 | [JsonProperty("TargetEndpoint", NullValueHandling = NullValueHandling.Ignore)]
50 | public Uri TargetEndpoint { get; set; }
51 |
52 | [JsonProperty("ResponseTimeUtc", NullValueHandling = NullValueHandling.Ignore)]
53 | public DateTimeOffset? ResponseTimeUtc { get; set; }
54 |
55 | [JsonProperty("ResourceType", NullValueHandling = NullValueHandling.Ignore)]
56 | public string ResourceType { get; set; }
57 |
58 | [JsonProperty("OperationType", NullValueHandling = NullValueHandling.Ignore)]
59 | public string OperationType { get; set; }
60 |
61 | [JsonProperty("LocationEndpoint", NullValueHandling = NullValueHandling.Ignore)]
62 | public Uri LocationEndpoint { get; set; }
63 |
64 | [JsonProperty("ActivityId", NullValueHandling = NullValueHandling.Ignore)]
65 | public Guid? ActivityId { get; set; }
66 |
67 | [JsonProperty("StoreResult", NullValueHandling = NullValueHandling.Ignore)]
68 | public string StoreResult { get; set; }
69 | }
70 |
71 | public partial class ContactedReplica
72 | {
73 | [JsonProperty("Count")]
74 | public long Count { get; set; }
75 |
76 | [JsonProperty("Uri")]
77 | public string Uri { get; set; }
78 | }
79 |
80 | public partial class Summary
81 | {
82 | [JsonProperty("StartUtc")]
83 | public DateTimeOffset StartUtc { get; set; }
84 |
85 | [JsonProperty("TotalElapsedTimeInMs")]
86 | public double TotalElapsedTimeInMs { get; set; }
87 |
88 | [JsonProperty("UserAgent")]
89 | public string UserAgent { get; set; }
90 |
91 | [JsonProperty("TotalRequestCount")]
92 | public long TotalRequestCount { get; set; }
93 |
94 | [JsonProperty("FailedRequestCount")]
95 | public long FailedRequestCount { get; set; }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/NumberService/GetConflicts.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.ApplicationInsights;
2 | using Microsoft.AspNetCore.Http;
3 | using Microsoft.AspNetCore.Mvc;
4 | using Microsoft.Azure.Cosmos;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.Extensions.Logging;
8 | using System;
9 | using System.Collections.Generic;
10 | using System.Threading.Tasks;
11 |
12 | namespace NumberService
13 | {
14 | public class GetConflicts
15 | {
16 | private readonly TelemetryClient _telemetry;
17 | private readonly Container _container;
18 |
19 | public GetConflicts(TelemetryClient telemetry, CosmosClient cosmos)
20 | {
21 | _telemetry = telemetry;
22 | _container = cosmos
23 | .GetContainer(
24 | Environment.GetEnvironmentVariable("CosmosDbDatabaseId"),
25 | Environment.GetEnvironmentVariable("CosmosDbContainerId"));
26 | }
27 |
28 | [FunctionName("GetConflicts")]
29 | public async Task Run(
30 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "conflicts/{key:alpha}")] HttpRequest req,
31 | ILogger log,
32 | string key)
33 | {
34 | // https://docs.microsoft.com/en-us/azure/cosmos-db/how-to-manage-conflicts?tabs=dotnetv3%2Capi-async%2Casync#read-from-conflict-feed
35 |
36 | var sql = new QueryDefinition("Select * from c where c.id = @key");
37 | sql.WithParameter("@key", key);
38 |
39 | FeedIterator conflictFeed = _container.Conflicts.GetConflictQueryIterator(sql);
40 |
41 | var conflictResults = new List();
42 |
43 | while (conflictFeed.HasMoreResults)
44 | {
45 | FeedResponse conflicts = await conflictFeed.ReadNextAsync();
46 | foreach (ConflictProperties conflict in conflicts)
47 | {
48 | var conflictResult = new ConflictResult();
49 |
50 | // Read the conflicted content
51 | conflictResult.Conflict = _container.Conflicts.ReadConflictContent(conflict);
52 |
53 | // If invalid conflict, log and break
54 | if (conflictResult.Conflict is null)
55 | {
56 | _telemetry.TrackTrace("GetConflicts: conflictResult.Conflict is null");
57 | break;
58 | }
59 |
60 | conflictResult.Current = await _container.Conflicts.ReadCurrentAsync(conflict, new PartitionKey(conflictResult.Conflict.Key));
61 | conflictResults.Add(conflictResult);
62 | }
63 | }
64 |
65 | return new OkObjectResult(conflictResults);
66 | }
67 | }
68 | }
69 |
70 |
--------------------------------------------------------------------------------
/src/NumberService/GetNumber.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.ApplicationInsights;
2 | using Microsoft.AspNetCore.Http;
3 | using Microsoft.AspNetCore.Mvc;
4 | using Microsoft.Azure.Cosmos;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using System;
8 | using System.Threading.Tasks;
9 |
10 | namespace NumberService
11 | {
12 | public class GetNumber
13 | {
14 | private readonly TelemetryClient _telemetry;
15 | private readonly Container _container;
16 |
17 | public GetNumber(TelemetryClient telemetry, CosmosClient cosmos)
18 | {
19 | _telemetry = telemetry;
20 | _container = cosmos
21 | .GetContainer(
22 | Environment.GetEnvironmentVariable("CosmosDbDatabaseId"),
23 | Environment.GetEnvironmentVariable("CosmosDbContainerId"));
24 | }
25 |
26 | [FunctionName("GetNumber")]
27 | public async Task Run(
28 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "numbers/{key:alpha}")] HttpRequest req,
29 | string key)
30 | {
31 |
32 |
33 | var startTime = DateTime.UtcNow;
34 | var timer = System.Diagnostics.Stopwatch.StartNew();
35 | Response response = null;
36 |
37 | try
38 | {
39 | response = await _container.ReadItemAsync(
40 | key,
41 | new PartitionKey(key));
42 | }
43 | finally
44 | {
45 | timer.Stop();
46 | _telemetry.TrackCosmosDependency(
47 | response,
48 | $"$key={key}",
49 | startTime,
50 | timer.Elapsed);
51 | }
52 |
53 | var number = response.Resource;
54 |
55 | return new OkObjectResult(number);
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/NumberService/NumberResult.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json;
2 |
3 | namespace NumberService
4 | {
5 | public class NumberResult
6 | {
7 | [JsonProperty("id")]
8 | public string Key { get; set; }
9 |
10 | public long Number { get; set; }
11 |
12 | public string ClientId { get; set; }
13 |
14 | [JsonProperty("_etag")]
15 | public string ETag { get; set; }
16 |
17 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
18 | public double? RequestCharge { get; set; }
19 |
20 | [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
21 | public CosmosDiagnostics CosmosDiagnostics { get; set; }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/NumberService/NumberService.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net6.0
4 | v4
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | PreserveNewest
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/NumberService/NumberService.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 16
4 | VisualStudioVersion = 16.0.30309.148
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NumberService", "NumberService.csproj", "{8FA25DE8-D07F-4C32-A1BF-62869974EECA}"
7 | EndProject
8 | Global
9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | Debug|Any CPU = Debug|Any CPU
11 | Release|Any CPU = Release|Any CPU
12 | EndGlobalSection
13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
14 | {8FA25DE8-D07F-4C32-A1BF-62869974EECA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15 | {8FA25DE8-D07F-4C32-A1BF-62869974EECA}.Debug|Any CPU.Build.0 = Debug|Any CPU
16 | {8FA25DE8-D07F-4C32-A1BF-62869974EECA}.Release|Any CPU.ActiveCfg = Release|Any CPU
17 | {8FA25DE8-D07F-4C32-A1BF-62869974EECA}.Release|Any CPU.Build.0 = Release|Any CPU
18 | EndGlobalSection
19 | GlobalSection(SolutionProperties) = preSolution
20 | HideSolutionNode = FALSE
21 | EndGlobalSection
22 | GlobalSection(ExtensibilityGlobals) = postSolution
23 | SolutionGuid = {AEECBAB9-8113-4F9B-97D0-771C42F7C1AD}
24 | EndGlobalSection
25 | EndGlobal
26 |
--------------------------------------------------------------------------------
/src/NumberService/Properties/serviceDependencies.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "storage1": {
4 | "type": "storage",
5 | "connectionId": "AzureWebJobsStorage"
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/src/NumberService/PutNumber.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.ApplicationInsights;
2 | using Microsoft.AspNetCore.Http;
3 | using Microsoft.AspNetCore.Mvc;
4 | using Microsoft.Azure.Cosmos;
5 | using Microsoft.Azure.WebJobs;
6 | using Microsoft.Azure.WebJobs.Extensions.Http;
7 | using Microsoft.Extensions.Logging;
8 | using Newtonsoft.Json;
9 | using System;
10 | using System.Collections.Generic;
11 | using System.Threading.Tasks;
12 |
13 | namespace NumberService
14 | {
15 | public class PutNumber
16 | {
17 | private static readonly string _clientId = Guid.NewGuid().ToString("N");
18 | private readonly TelemetryClient _telemetry;
19 | private readonly Container _container;
20 |
21 | public PutNumber(TelemetryClient telemetry, CosmosClient cosmos)
22 | {
23 | _telemetry = telemetry;
24 | _container = cosmos
25 | .GetContainer(
26 | Environment.GetEnvironmentVariable("CosmosDbDatabaseId"),
27 | Environment.GetEnvironmentVariable("CosmosDbContainerId"));
28 | }
29 |
30 | [FunctionName("PutNumber")]
31 | public async Task Run(
32 | ILogger log,
33 | [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "numbers/{key:alpha}")] HttpRequest req,
34 | string key)
35 | {
36 | var startTime = DateTime.UtcNow;
37 | var timer = System.Diagnostics.Stopwatch.StartNew();
38 | Response response = null;
39 |
40 | try
41 | {
42 | response = await _container.Scripts.ExecuteStoredProcedureAsync(
43 | "incrementNumber",
44 | new PartitionKey(key),
45 | new[] { key, _clientId });
46 | }
47 | finally
48 | {
49 | timer.Stop();
50 | _telemetry.TrackCosmosDependency(
51 | response,
52 | $"incrementNumber $key={key}, $_clientId={_clientId}",
53 | startTime,
54 | timer.Elapsed);
55 | }
56 |
57 | var number = response.Resource;
58 | number.RequestCharge = response.RequestCharge;
59 |
60 | // if query string contains ?diagnostics, return CosmosDiagnostics
61 | if (req.Query.ContainsKey("diagnostics"))
62 | {
63 | try
64 | {
65 | number.CosmosDiagnostics = JsonConvert.DeserializeObject(response.Diagnostics.ToString());
66 | }
67 | catch (Exception ex)
68 | {
69 | log.LogError(ex, "Could not deserialize Diagnostics");
70 | }
71 | }
72 |
73 | // As long as sproc is written correctly, this case should never be true.
74 | if (number.ClientId != _clientId) throw new InvalidOperationException($"Response ClientId \"{number.ClientId}\" does not match ClientId \"{_clientId}\".");
75 |
76 | log.LogInformation($"Number {number.Number} issued to clientId {number.ClientId} with ETag {number.ETag} from key {number.Key}");
77 |
78 | _telemetry.TrackEvent(
79 | "PutNumber",
80 | properties: new Dictionary
81 | {
82 | { "Number", number.Number.ToString() },
83 | { "ClientId", _clientId },
84 | { "Key", number.Key },
85 | { "ETag", number.ETag }
86 | },
87 | metrics: new Dictionary
88 | {
89 | { "CosmosRequestCharge", response.RequestCharge }
90 | });
91 |
92 | return new OkObjectResult(number);
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/NumberService/Startup.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Azure.Cosmos;
2 | using Microsoft.Azure.Functions.Extensions.DependencyInjection;
3 | using Microsoft.Extensions.DependencyInjection;
4 | using System;
5 | using System.Linq;
6 |
7 | [assembly: FunctionsStartup(typeof(NumberService.Startup))]
8 |
9 | namespace NumberService
10 | {
11 | public class Startup : FunctionsStartup
12 | {
13 | public override void Configure(IFunctionsHostBuilder builder)
14 | {
15 | builder.Services.AddSingleton((c) =>
16 | {
17 | var options = new CosmosClientOptions();
18 | string preferredRegions = Environment.GetEnvironmentVariable("CosmosApplicationPreferredRegions");
19 |
20 | if (!string.IsNullOrEmpty(preferredRegions))
21 | {
22 | var regions = preferredRegions.Split(';').ToList();
23 | options.ApplicationPreferredRegions = regions;
24 | }
25 |
26 | return new CosmosClient(Environment.GetEnvironmentVariable("CosmosDbConnectionString"), options);
27 | });
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/src/NumberService/TelemetryClientCosmosExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.ApplicationInsights.DataContracts;
2 | using Microsoft.Azure.Cosmos;
3 | using Newtonsoft.Json;
4 | using System;
5 | using System.Linq;
6 |
7 | namespace Microsoft.ApplicationInsights
8 | {
9 | internal static class TelemetryClientCosmosExtensions
10 | {
11 | ///
12 | /// Track a Cosmos dependency call in Azure Monitor Application Insights
13 | ///
14 | /// The type parameter of the Cosmos Response
15 | /// An initialised TelemetryClient
16 | /// The Cosmos Response returned
17 | /// The command text to display in the dependency trace. NOTE: Do not include PII or Secrets
18 | /// The timestamp for this call. Usually the DateTimeOffset.UtcNow immediately before the dependency call was made.
19 | /// The measured duration of the dependency call as TimeSpan
20 | public static void TrackCosmosDependency(
21 | this TelemetryClient telemetry,
22 | Response response,
23 | string command,
24 | DateTimeOffset timestamp,
25 | TimeSpan duration)
26 | {
27 | try
28 | {
29 | var diagnostics = Diagnostics(response);
30 |
31 | if (diagnostics is null || diagnostics.Context is null)
32 | {
33 | telemetry.TrackTrace("Cosmos Dependency Diagnostics deserialization error. var diagnostics is null", SeverityLevel.Warning);
34 | return;
35 | }
36 |
37 | var statistics = diagnostics.Context.FirstOrDefault(c => c.Id == "StoreResponseStatistics");
38 |
39 | var dependency = new DependencyTelemetry
40 | {
41 | Data = command,
42 | Duration = duration,
43 | Name = Name(statistics),
44 | ResultCode = StatusCode(response),
45 | Timestamp = timestamp,
46 | Success = StatusCodeSuccess(response),
47 | Target = statistics?.LocationEndpoint?.Host,
48 | Type = "Azure DocumentDB" // This type name will display the Cosmos icon in the UI
49 | };
50 |
51 | telemetry.TrackDependency(dependency);
52 | }
53 | catch (Exception ex)
54 | {
55 | // log and continue
56 | telemetry.TrackException(ex);
57 | }
58 | }
59 |
60 | private static string Name(NumberService.Context statistics)
61 | {
62 | if (statistics is null) return null;
63 | return $"{statistics.ResourceType} {statistics.OperationType}";
64 | }
65 |
66 | private static bool StatusCodeSuccess(Response response)
67 | {
68 | if (response is null) return false;
69 | if ((int)response.StatusCode >= 400) return false;
70 | return true;
71 | }
72 |
73 | private static string StatusCode(Response response)
74 | {
75 | if (response is null) return null;
76 | return ((int)response.StatusCode).ToString();
77 | }
78 |
79 | //private static string FirstDotPartOfHostname(string host)
80 | //{
81 | // if (string.IsNullOrEmpty(host)) return null;
82 | // var parts = host.Split(".");
83 | // if (parts.Any()) return parts[0];
84 | // return host;
85 | //}
86 |
87 | private static NumberService.CosmosDiagnostics Diagnostics(Response response)
88 | {
89 | if (response is null || response.Diagnostics is null) return null;
90 | return JsonConvert.DeserializeObject(response.Diagnostics.ToString());
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/NumberService/host.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0",
3 | "logging": {
4 | "applicationInsights": {
5 | "samplingExcludedTypes": "Request",
6 | "samplingSettings": {
7 | "isEnabled": true
8 | }
9 | }
10 | }
11 | }
--------------------------------------------------------------------------------
/test/test-multiregion.ps1:
--------------------------------------------------------------------------------
1 | $numbers = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()
2 |
3 | $i = 0
4 | while ($true) {
5 | $i++
6 | 'https://numberservice-aue.azurewebsites.net/api/numbers/free?diagnostics', 'https://numberservice-ase.azurewebsites.net/api/numbers/free?diagnostics' | ForEach-Object -Parallel {
7 |
8 | $response = $null
9 | $response = Invoke-RestMethod -Method Put -Uri $_
10 | Write-Host $response.number -ForegroundColor Yellow
11 | Write-Host $response
12 | Write-Host ( $response.CosmosDiagnostics.Context | Where-Object -Property Id -eq 'StoreResponseStatistics' )
13 |
14 | $numbers = $using:numbers
15 | $numbers[$_] = $response.number
16 | }
17 |
18 | if ($numbers['https://numberservice-aue.azurewebsites.net/api/numbers/free?diagnostics'] -eq $numbers['https://numberservice-ase.azurewebsites.net/api/numbers/free?diagnostics']) {
19 | Write-Host "COLLISION after $i tests" -ForegroundColor Red
20 | Write-Host $numbers['https://numberservice-aue.azurewebsites.net/api/numbers/free?diagnostics'] -ForegroundColor Yellow
21 | Write-Host $numbers['https://numberservice-ase.azurewebsites.net/api/numbers/free?diagnostics'] -ForegroundColor Yellow
22 | break
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/test/test.ps1:
--------------------------------------------------------------------------------
1 | while ($true) {
2 | $response = $null
3 | $response = Invoke-RestMethod -Method Put -Uri 'https://numberservice-aue.azurewebsites.net/api/numbers/free'
4 |
5 | if ($null -ne $response) {
6 | if ($response.Number -ne $lastNumber + 1) {
7 | Write-Host $response -ForegroundColor Yellow
8 | Write-Error $response
9 | } else {
10 | Write-Host $response -ForegroundColor White
11 | }
12 | $lastNumber = $response.Number
13 | }
14 |
15 | $response = $null
16 | $response = Invoke-RestMethod -Method Get -Uri 'https://numberservice-ase.azurewebsites.net/api/numbers/free'
17 |
18 | if ($null -ne $response) {
19 | if ($response.Number -ne $lastNumber) {
20 | Write-Host $response -ForegroundColor Yellow
21 | Write-Error $response
22 | } else {
23 | Write-Host $response -ForegroundColor White
24 | }
25 | $lastNumber = $response.Number
26 | Write-Host
27 | }
28 |
29 | $response = $null
30 | $response = Invoke-RestMethod -Method Put -Uri 'https://numberservice-ase.azurewebsites.net/api/numbers/free'
31 |
32 | if ($null -ne $response) {
33 | if ($response.Number -ne $lastNumber + 1) {
34 | Write-Host $response -ForegroundColor Yellow
35 | Write-Error $response
36 | } else {
37 | Write-Host $response -ForegroundColor White
38 | }
39 | $lastNumber = $response.Number
40 | }
41 |
42 | $response = $null
43 | $response = Invoke-RestMethod -Method Get -Uri 'https://numberservice-aue.azurewebsites.net/api/numbers/free'
44 |
45 | if ($null -ne $response) {
46 | if ($response.Number -ne $lastNumber) {
47 | Write-Host $response -ForegroundColor Yellow
48 | Write-Error $response
49 | } else {
50 | Write-Host $response -ForegroundColor White
51 | }
52 | $lastNumber = $response.Number
53 | Write-Host
54 | }
55 |
56 | #Start-Sleep -Seconds 5
57 | }
--------------------------------------------------------------------------------