├── .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 | } --------------------------------------------------------------------------------