├── .gitattributes ├── .github ├── renovate.json └── workflows │ ├── ci-build.yml │ ├── ci-buildAndRelease.yml │ └── lock.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── images └── logo.png ├── src ├── Directory.build.props ├── Directory.build.targets ├── Punchclock.Tests │ ├── API │ │ ├── ApiApprovalTests.PunchclockTests.DotNet6_0.verified.txt │ │ ├── ApiApprovalTests.PunchclockTests.DotNet7_0.verified.txt │ │ ├── ApiApprovalTests.PunchclockTests.DotNet8_0.verified.txt │ │ ├── ApiApprovalTests.PunchclockTests.Net4_7.verified.txt │ │ ├── ApiApprovalTests.cs │ │ └── ApiExtensions.cs │ ├── OperationQueueTests.cs │ └── Punchclock.Tests.csproj ├── Punchclock.sln ├── Punchclock │ ├── KeyedOperation.cs │ ├── OperationQueue.cs │ ├── OperationQueueExtensions.cs │ ├── PriorityQueue.cs │ ├── PrioritySemaphoreSubject.cs │ ├── Punchclock.csproj │ └── ScheduledSubject.cs ├── analyzers.ruleset └── stylecop.json └── version.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.doc diff=astextplain 2 | *.DOC diff=astextplain 3 | *.docx diff=astextplain 4 | *.DOCX diff=astextplain 5 | *.dot diff=astextplain 6 | *.DOT diff=astextplain 7 | *.pdf diff=astextplain 8 | *.PDF diff=astextplain 9 | *.rtf diff=astextplain 10 | *.RTF diff=astextplain 11 | 12 | *.jpg binary 13 | *.png binary 14 | *.gif binary 15 | 16 | *.cs text diff=csharp 17 | *.vb text 18 | *.c text 19 | *.cpp text 20 | *.cxx text 21 | *.h text 22 | *.hxx text 23 | *.py text 24 | *.rb text 25 | *.java text 26 | *.html text 27 | *.htm text 28 | *.css text 29 | *.scss text 30 | *.sass text 31 | *.less text 32 | *.js text 33 | *.lisp text 34 | *.clj text 35 | *.sql text 36 | *.php text 37 | *.lua text 38 | *.m text 39 | *.asm text 40 | *.erl text 41 | *.fs text 42 | *.fsx text 43 | *.hs text 44 | 45 | *.csproj text merge=union 46 | *.vbproj text merge=union 47 | *.fsproj text merge=union 48 | *.dbproj text merge=union 49 | *.sln text eol=crlf merge=union 50 | 51 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>reactiveui/.github:renovate"] 4 | } -------------------------------------------------------------------------------- /.github/workflows/ci-build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | productNamespacePrefix: "Punchclock" 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | permissions: 18 | contents: none 19 | uses: reactiveui/actions-common/.github/workflows/workflow-common-setup-and-build.yml@main 20 | with: 21 | configuration: Release 22 | productNamespacePrefix: "Punchclock" 23 | installWorkflows: false 24 | -------------------------------------------------------------------------------- /.github/workflows/ci-buildAndRelease.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - patches/* 8 | 9 | env: 10 | productNamespacePrefix: "Punchclock" 11 | 12 | permissions: 13 | contents: write 14 | 15 | jobs: 16 | release: 17 | uses: reactiveui/actions-common/.github/workflows/workflow-common-release.yml@main 18 | with: 19 | configuration: Release 20 | productNamespacePrefix: "Punchclock" 21 | installWorkflows: false 22 | secrets: 23 | SIGN_CLIENT_USER_ID: ${{ secrets.SIGN_CLIENT_USER_ID }} 24 | SIGN_CLIENT_SECRET: ${{ secrets.SIGN_CLIENT_SECRET }} 25 | SIGN_CLIENT_CONFIG: ${{ secrets.SIGN_CLIENT_CONFIG }} 26 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} 27 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | name: 'Lock Threads' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | concurrency: 13 | group: lock 14 | 15 | jobs: 16 | action: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: dessant/lock-threads@v5 20 | with: 21 | github-token: ${{ github.token }} 22 | issue-inactive-days: '14' 23 | pr-inactive-days: '14' 24 | issue-comment: > 25 | This issue has been automatically locked since there 26 | has not been any recent activity after it was closed. 27 | Please open a new issue for related bugs. 28 | pr-comment: > 29 | This pull request has been automatically locked since there 30 | has not been any recent activity after it was closed. 31 | Please open a new issue for related bugs. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | [Bb]uild/ 25 | [Tt]ools/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # DNX 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | 50 | *_i.c 51 | *_p.c 52 | *_i.h 53 | *.ilk 54 | *.meta 55 | *.obj 56 | *.pch 57 | *.pdb 58 | *.pgc 59 | *.pgd 60 | *.rsp 61 | *.sbr 62 | *.tlb 63 | *.tli 64 | *.tlh 65 | *.tmp 66 | *.tmp_proj 67 | *.log 68 | *.vspscc 69 | *.vssscc 70 | .builds 71 | *.pidb 72 | *.svclog 73 | *.scc 74 | 75 | # Chutzpah Test files 76 | _Chutzpah* 77 | 78 | # Visual C++ cache files 79 | ipch/ 80 | *.aps 81 | *.ncb 82 | *.opendb 83 | *.opensdf 84 | *.sdf 85 | *.cachefile 86 | *.VC.db 87 | *.VC.VC.opendb 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | *.sap 94 | 95 | # TFS 2012 Local Workspace 96 | $tf/ 97 | 98 | # Guidance Automation Toolkit 99 | *.gpState 100 | 101 | # ReSharper is a .NET coding add-in 102 | _ReSharper*/ 103 | *.[Rr]e[Ss]harper 104 | *.DotSettings.user 105 | 106 | # JustCode is a .NET coding add-in 107 | .JustCode 108 | 109 | # TeamCity is a build add-in 110 | _TeamCity* 111 | 112 | # DotCover is a Code Coverage Tool 113 | *.dotCover 114 | 115 | # NCrunch 116 | _NCrunch_* 117 | .*crunch*.local.xml 118 | nCrunchTemp_* 119 | 120 | # MightyMoose 121 | *.mm.* 122 | AutoTest.Net/ 123 | 124 | # Web workbench (sass) 125 | .sass-cache/ 126 | 127 | # Installshield output folder 128 | [Ee]xpress/ 129 | 130 | # DocProject is a documentation generator add-in 131 | DocProject/buildhelp/ 132 | DocProject/Help/*.HxT 133 | DocProject/Help/*.HxC 134 | DocProject/Help/*.hhc 135 | DocProject/Help/*.hhk 136 | DocProject/Help/*.hhp 137 | DocProject/Help/Html2 138 | DocProject/Help/html 139 | 140 | # Click-Once directory 141 | publish/ 142 | 143 | # Publish Web Output 144 | *.[Pp]ublish.xml 145 | *.azurePubxml 146 | # TODO: Comment the next line if you want to checkin your web deploy settings 147 | # but database connection strings (with potential passwords) will be unencrypted 148 | *.pubxml 149 | *.publishproj 150 | 151 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 152 | # checkin your Azure Web App publish settings, but sensitive information contained 153 | # in these scripts will be unencrypted 154 | PublishScripts/ 155 | 156 | # NuGet Packages 157 | *.nupkg 158 | # The packages folder can be ignored because of Package Restore 159 | **/packages/* 160 | # except build/, which is used as an MSBuild target. 161 | !**/packages/build/ 162 | # Uncomment if necessary however generally it will be regenerated when needed 163 | #!**/packages/repositories.config 164 | # NuGet v3's project.json files produces more ignoreable files 165 | *.nuget.props 166 | *.nuget.targets 167 | 168 | # Microsoft Azure Build Output 169 | csx/ 170 | *.build.csdef 171 | 172 | # Microsoft Azure Emulator 173 | ecf/ 174 | rcf/ 175 | 176 | # Windows Store app package directories and files 177 | AppPackages/ 178 | BundleArtifacts/ 179 | Package.StoreAssociation.xml 180 | _pkginfo.txt 181 | 182 | # Visual Studio cache files 183 | # files ending in .cache can be ignored 184 | *.[Cc]ache 185 | # but keep track of directories ending in .cache 186 | !*.[Cc]ache/ 187 | 188 | # Others 189 | ClientBin/ 190 | ~$* 191 | *~ 192 | *.dbmdl 193 | *.dbproj.schemaview 194 | *.pfx 195 | *.publishsettings 196 | node_modules/ 197 | orleans.codegen.cs 198 | 199 | # Since there are multiple workflows, uncomment next line to ignore bower_components 200 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 201 | #bower_components/ 202 | 203 | # RIA/Silverlight projects 204 | Generated_Code/ 205 | 206 | # Backup & report files from converting an old project file 207 | # to a newer Visual Studio version. Backup files are not needed, 208 | # because we have git ;-) 209 | _UpgradeReport_Files/ 210 | Backup*/ 211 | UpgradeLog*.XML 212 | UpgradeLog*.htm 213 | 214 | # SQL Server files 215 | *.mdf 216 | *.ldf 217 | 218 | # Business Intelligence projects 219 | *.rdl.data 220 | *.bim.layout 221 | *.bim_*.settings 222 | 223 | # Microsoft Fakes 224 | FakesAssemblies/ 225 | 226 | # GhostDoc plugin setting file 227 | *.GhostDoc.xml 228 | 229 | # Node.js Tools for Visual Studio 230 | .ntvs_analysis.dat 231 | 232 | # Visual Studio 6 build log 233 | *.plg 234 | 235 | # Visual Studio 6 workspace options file 236 | *.opt 237 | 238 | # Visual Studio LightSwitch build output 239 | **/*.HTMLClient/GeneratedArtifacts 240 | **/*.DesktopClient/GeneratedArtifacts 241 | **/*.DesktopClient/ModelManifest.xml 242 | **/*.Server/GeneratedArtifacts 243 | **/*.Server/ModelManifest.xml 244 | _Pvt_Extensions 245 | 246 | # Paket dependency manager 247 | .paket/paket.exe 248 | paket-files/ 249 | 250 | # FAKE - F# Make 251 | .fake/ 252 | 253 | # JetBrains Rider 254 | .idea/ 255 | *.sln.iml 256 | 257 | # CodeRush 258 | .cr/ 259 | 260 | # Python Tools for Visual Studio (PTVS) 261 | __pycache__/ 262 | *.pyc 263 | 264 | # VS Code configurations 265 | .vscode/ 266 | 267 | tools/ 268 | 269 | punchclock.binlog 270 | 271 | src/*.Tests/**/ApiApprovalTests*.received.txt 272 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This Code of Conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at anais@anaisbetts.org. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | 45 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 46 | version 1.3.0, available at 47 | [https://contributor-covenant.org/version/1/3/0/][version] 48 | 49 | [homepage]: https://contributor-covenant.org 50 | [version]: https://contributor-covenant.org/version/1/3/0/ 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Anaïs Betts 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NuGet Stats](https://img.shields.io/nuget/v/punchclock.svg)](https://www.nuget.org/packages/punchclock) ![Build](https://github.com/reactiveui/punchclock/workflows/Build/badge.svg) 2 | [![Code Coverage](https://codecov.io/gh/reactiveui/punchclock/branch/main/graph/badge.svg)](https://codecov.io/gh/reactiveui/punchclock) [![#yourfirstpr](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://reactiveui.net/contribute) 3 |
4 | 5 |
6 | 7 | 8 | 9 | 10 | ## Punchclock: A library for managing concurrent operations 11 | 12 | Punchclock is the low-level scheduling and prioritization library used by 13 | [Fusillade](https://github.com/reactiveui/Fusillade) to orchestrate pending 14 | concurrent operations. 15 | 16 | ### What even does that mean? 17 | 18 | Ok, so you've got a shiny mobile phone app and you've got async/await. 19 | Awesome! It's so easy to issue network requests, why not do it all the time? 20 | After your users one-:star2: you for your app being slow, you discover that 21 | you're issuing *way* too many requests at the same time. 22 | 23 | Then, you try to manage issuing less requests by hand, and it becomes a 24 | spaghetti mess as different parts of your app reach into each other to try to 25 | figure out who's doing what. Let's figure out a better way. 26 | 27 | ### So many words, gimme the examples 28 | 29 | ```cs 30 | var wc = new WebClient(); 31 | var opQueue = new OperationQueue(2 /*at a time*/); 32 | 33 | // Download a bunch of images 34 | var foo = opQueue.Enqueue(1, 35 | () => wc.DownloadFile("https://example.com/foo.jpg", "foo.jpg")); 36 | var bar = opQueue.Enqueue(1, 37 | () => wc.DownloadFile("https://example.com/bar.jpg", "bar.jpg")); 38 | var baz = opQueue.Enqueue(1, 39 | () => wc.DownloadFile("https://example.com/baz.jpg", "baz.jpg")); 40 | var bamf = opQueue.Enqueue(1, 41 | () => wc.DownloadFile("https://example.com/bamf.jpg", "bamf.jpg")); 42 | 43 | // We'll be downloading the images two at a time, even though we started 44 | // them all at once 45 | await Task.WaitAll(foo, bar, baz, bamf); 46 | ``` 47 | 48 | Now, in a completely different part of your app, if you need something right 49 | away, you can specify it via the priority: 50 | 51 | ```cs 52 | // This file is super important, we don't care if it cuts in line in front 53 | // of some images or other stuff 54 | var wc = new WebClient(); 55 | await opQueue.Enqueue(10 /* It's Important */, 56 | () => wc.DownloadFileTaskAsync("http://example.com/cool.txt", "./cool.txt")); 57 | ``` 58 | 59 | ## What else can this library do 60 | 61 | * Cancellation via CancellationTokens or via Observables 62 | * Ensure certain operations don't run concurrently via a key 63 | * Queue pause / resume 64 | 65 | ## Contribute 66 | 67 | Punchclock is developed under an OSI-approved open source license, making it freely usable and distributable, even for commercial use. Because of our Open Collective model for funding and transparency, we are able to funnel support and funds through to our contributors and community. We ❤ the people who are involved in this project, and we’d love to have you on board, especially if you are just getting started or have never contributed to open-source before. 68 | 69 | So here's to you, lovely person who wants to join us — this is how you can support us: 70 | 71 | * [Responding to questions on StackOverflow](https://stackoverflow.com/questions/tagged/punchclock) 72 | * [Passing on knowledge and teaching the next generation of developers](https://ericsink.com/entries/dont_use_rxui.html) 73 | * Submitting documentation updates where you see fit or lacking. 74 | * Making contributions to the code base. 75 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reactiveui/punchclock/255dcc3b7b2d163250575ed306c815c1f68dcedf/images/logo.png -------------------------------------------------------------------------------- /src/Directory.build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | Copyright (c) .NET Foundation and Contributors 4 | MIT 5 | https://github.com/reactiveui/punchclock/ 6 | .NET Foundation and Contributors 7 | xanaisbettsx;ghuntley 8 | $(NoWarn);VSX1000;SA1010 9 | AnyCPU 10 | README.md 11 | logo.png 12 | Make sure your asynchronous operations show up to work on time 13 | https://github.com/reactiveui/punchclock/releases 14 | https://github.com/reactiveui/punchclock 15 | git 16 | true 17 | $(MSBuildThisFileDirectory)analyzers.ruleset 18 | $(MSBuildProjectName.Contains('Tests')) 19 | Embedded 20 | 21 | true 22 | 23 | true 24 | 25 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb 26 | 27 | True 28 | latest 29 | preview 30 | 31 | 32 | 33 | false 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/Directory.build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | $(AssemblyName) ($(TargetFramework)) 4 | 5 | 6 | 7 | $(DefineConstants);NET_45;XAML 8 | 9 | 10 | $(DefineConstants);NETFX_CORE;XAML;WINDOWS_UWP 11 | 12 | 13 | $(DefineConstants);MONO;UIKIT;COCOA 14 | 15 | 16 | $(DefineConstants);MONO;COCOA 17 | 18 | 19 | $(DefineConstants);MONO;UIKIT;COCOA 20 | 21 | 22 | $(DefineConstants);MONO;UIKIT;COCOA 23 | 24 | 25 | $(DefineConstants);MONO;ANDROID 26 | 27 | 28 | $(DefineConstants);TIZEN 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Punchclock.Tests/API/ApiApprovalTests.PunchclockTests.DotNet6_0.verified.txt: -------------------------------------------------------------------------------- 1 | [assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] 2 | namespace Punchclock 3 | { 4 | public class OperationQueue : System.IDisposable 5 | { 6 | public OperationQueue(int maximumConcurrent = 4) { } 7 | public void Dispose() { } 8 | protected virtual void Dispose(bool isDisposing) { } 9 | public System.IObservable EnqueueObservableOperation(int priority, System.Func> asyncCalculationFunc) { } 10 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.Func> asyncCalculationFunc) { } 11 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.IObservable cancel, System.Func> asyncCalculationFunc) { } 12 | public System.IDisposable PauseQueue() { } 13 | public void SetMaximumConcurrent(int maximumConcurrent) { } 14 | public System.IObservable ShutdownQueue() { } 15 | } 16 | public static class OperationQueueExtensions 17 | { 18 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func asyncOperation) { } 19 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation) { } 20 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation, System.Threading.CancellationToken token) { } 21 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func> asyncOperation) { } 22 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation) { } 23 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation, System.Threading.CancellationToken token) { } 24 | } 25 | } -------------------------------------------------------------------------------- /src/Punchclock.Tests/API/ApiApprovalTests.PunchclockTests.DotNet7_0.verified.txt: -------------------------------------------------------------------------------- 1 | [assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] 2 | namespace Punchclock 3 | { 4 | public class OperationQueue : System.IDisposable 5 | { 6 | public OperationQueue(int maximumConcurrent = 4) { } 7 | public void Dispose() { } 8 | protected virtual void Dispose(bool isDisposing) { } 9 | public System.IObservable EnqueueObservableOperation(int priority, System.Func> asyncCalculationFunc) { } 10 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.Func> asyncCalculationFunc) { } 11 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.IObservable cancel, System.Func> asyncCalculationFunc) { } 12 | public System.IDisposable PauseQueue() { } 13 | public void SetMaximumConcurrent(int maximumConcurrent) { } 14 | public System.IObservable ShutdownQueue() { } 15 | } 16 | public static class OperationQueueExtensions 17 | { 18 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func asyncOperation) { } 19 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation) { } 20 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation, System.Threading.CancellationToken token) { } 21 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func> asyncOperation) { } 22 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation) { } 23 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation, System.Threading.CancellationToken token) { } 24 | } 25 | } -------------------------------------------------------------------------------- /src/Punchclock.Tests/API/ApiApprovalTests.PunchclockTests.DotNet8_0.verified.txt: -------------------------------------------------------------------------------- 1 | [assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] 2 | namespace Punchclock 3 | { 4 | public class OperationQueue : System.IDisposable 5 | { 6 | public OperationQueue(int maximumConcurrent = 4) { } 7 | public void Dispose() { } 8 | protected virtual void Dispose(bool isDisposing) { } 9 | public System.IObservable EnqueueObservableOperation(int priority, System.Func> asyncCalculationFunc) { } 10 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.Func> asyncCalculationFunc) { } 11 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.IObservable cancel, System.Func> asyncCalculationFunc) { } 12 | public System.IDisposable PauseQueue() { } 13 | public void SetMaximumConcurrent(int maximumConcurrent) { } 14 | public System.IObservable ShutdownQueue() { } 15 | } 16 | public static class OperationQueueExtensions 17 | { 18 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func asyncOperation) { } 19 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation) { } 20 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation, System.Threading.CancellationToken token) { } 21 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func> asyncOperation) { } 22 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation) { } 23 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation, System.Threading.CancellationToken token) { } 24 | } 25 | } -------------------------------------------------------------------------------- /src/Punchclock.Tests/API/ApiApprovalTests.PunchclockTests.Net4_7.verified.txt: -------------------------------------------------------------------------------- 1 | [assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] 2 | namespace Punchclock 3 | { 4 | public class OperationQueue : System.IDisposable 5 | { 6 | public OperationQueue(int maximumConcurrent = 4) { } 7 | public void Dispose() { } 8 | protected virtual void Dispose(bool isDisposing) { } 9 | public System.IObservable EnqueueObservableOperation(int priority, System.Func> asyncCalculationFunc) { } 10 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.Func> asyncCalculationFunc) { } 11 | public System.IObservable EnqueueObservableOperation(int priority, string key, System.IObservable cancel, System.Func> asyncCalculationFunc) { } 12 | public System.IDisposable PauseQueue() { } 13 | public void SetMaximumConcurrent(int maximumConcurrent) { } 14 | public System.IObservable ShutdownQueue() { } 15 | } 16 | public static class OperationQueueExtensions 17 | { 18 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func asyncOperation) { } 19 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation) { } 20 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func asyncOperation, System.Threading.CancellationToken token) { } 21 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, System.Func> asyncOperation) { } 22 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation) { } 23 | public static System.Threading.Tasks.Task Enqueue(this Punchclock.OperationQueue operationQueue, int priority, string key, System.Func> asyncOperation, System.Threading.CancellationToken token) { } 24 | } 25 | } -------------------------------------------------------------------------------- /src/Punchclock.Tests/API/ApiApprovalTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | using System.Threading.Tasks; 8 | using Xunit; 9 | 10 | namespace Punchclock.APITests; 11 | 12 | /// 13 | /// Tests for handling API approval. 14 | /// 15 | [ExcludeFromCodeCoverage] 16 | public class ApiApprovalTests 17 | { 18 | /// 19 | /// Tests to make sure the punchclock project is approved. 20 | /// 21 | /// A representing the asynchronous unit test. 22 | [Fact] 23 | public Task PunchclockTests() => typeof(OperationQueue).Assembly.CheckApproval(["Punchclock"]); 24 | } 25 | -------------------------------------------------------------------------------- /src/Punchclock.Tests/API/ApiExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Reflection; 8 | using System.Runtime.CompilerServices; 9 | using System.Threading.Tasks; 10 | using PublicApiGenerator; 11 | using VerifyXunit; 12 | 13 | namespace Punchclock.APITests; 14 | 15 | /// 16 | /// A helper for doing API approvals. 17 | /// 18 | public static class ApiExtensions 19 | { 20 | /// 21 | /// Checks to make sure the API is approved. 22 | /// 23 | /// The assembly that is being checked. 24 | /// The namespaces. 25 | /// The caller file path. 26 | /// 27 | /// A Task. 28 | /// 29 | public static async Task CheckApproval(this Assembly assembly, string[] namespaces, [CallerFilePath] string filePath = "") 30 | { 31 | var generatorOptions = new ApiGeneratorOptions { AllowNamespacePrefixes = namespaces }; 32 | var apiText = assembly.GeneratePublicApi(generatorOptions); 33 | var result = await Verifier.Verify(apiText, null, filePath) 34 | .UniqueForRuntimeAndVersion() 35 | .ScrubEmptyLines() 36 | .ScrubLines(l => 37 | l.StartsWith("[assembly: AssemblyVersion(", StringComparison.InvariantCulture) || 38 | l.StartsWith("[assembly: AssemblyFileVersion(", StringComparison.InvariantCulture) || 39 | l.StartsWith("[assembly: AssemblyInformationalVersion(", StringComparison.InvariantCulture) || 40 | l.StartsWith("[assembly: System.Reflection.AssemblyMetadata(", StringComparison.InvariantCulture)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Punchclock.Tests/OperationQueueTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Linq; 8 | using System.Reactive; 9 | using System.Reactive.Concurrency; 10 | using System.Reactive.Linq; 11 | using System.Reactive.Subjects; 12 | using DynamicData; 13 | using DynamicData.Binding; 14 | using Xunit; 15 | 16 | namespace Punchclock.Tests 17 | { 18 | /// 19 | /// Tests for the operation queue. 20 | /// 21 | public class OperationQueueTests 22 | { 23 | /// 24 | /// Checks to make sure that items are dispatched based on their priority. 25 | /// 26 | [Fact] 27 | public void ItemsShouldBeDispatchedByPriority() 28 | { 29 | var subjects = Enumerable.Range(0, 5).Select(x => new AsyncSubject()).ToArray(); 30 | var priorities = new[] { 5, 5, 5, 10, 1, }; 31 | var fixture = new OperationQueue(2); 32 | 33 | // The two at the front are solely to stop up the queue, they get subscribed 34 | // to immediately. 35 | var outputs = subjects.Zip( 36 | priorities, 37 | (inp, pri) => 38 | { 39 | fixture 40 | .EnqueueObservableOperation(pri, () => inp) 41 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 42 | .Bind(out var y).Subscribe(); 43 | return y; 44 | }).ToArray(); 45 | 46 | // Alright, we've got the first two subjects taking up our two live 47 | // slots, and 3,4,5 queued up. However, the order of completion should 48 | // be "4,3,5" because of the priority. 49 | Assert.True(outputs.All(x => x.Count == 0)); 50 | 51 | subjects[0].OnNext(42); 52 | subjects[0].OnCompleted(); 53 | Assert.Equal(new[] { 1, 0, 0, 0, 0, }, outputs.Select(x => x.Count)); 54 | 55 | // 0 => completed, 1,3 => live, 2,4 => queued. Make sure 4 *doesn't* fire because 56 | // the priority should invert it. 57 | subjects[4].OnNext(42); 58 | subjects[4].OnCompleted(); 59 | Assert.Equal(new[] { 1, 0, 0, 0, 0, }, outputs.Select(x => x.Count)); 60 | 61 | // At the end, 0,1 => completed, 3,2 => live, 4 is queued 62 | subjects[1].OnNext(42); 63 | subjects[1].OnCompleted(); 64 | Assert.Equal(new[] { 1, 1, 0, 0, 0, }, outputs.Select(x => x.Count)); 65 | 66 | // At the end, 0,1,2,4 => completed, 3 is live (remember, we completed 67 | // 4 early) 68 | subjects[2].OnNext(42); 69 | subjects[2].OnCompleted(); 70 | Assert.Equal(new[] { 1, 1, 1, 0, 1, }, outputs.Select(x => x.Count)); 71 | 72 | subjects[3].OnNext(42); 73 | subjects[3].OnCompleted(); 74 | Assert.Equal(new[] { 1, 1, 1, 1, 1, }, outputs.Select(x => x.Count)); 75 | } 76 | 77 | /// 78 | /// Checks to make sure that keyed items are serialized. 79 | /// 80 | [Fact] 81 | public void KeyedItemsShouldBeSerialized() 82 | { 83 | var subj1 = new AsyncSubject(); 84 | var subj2 = new AsyncSubject(); 85 | 86 | var subscribeCount1 = 0; 87 | var input1Subj = new AsyncSubject(); 88 | var input1 = Observable.Defer(() => 89 | { 90 | subscribeCount1++; 91 | return input1Subj; 92 | }); 93 | var subscribeCount2 = 0; 94 | var input2Subj = new AsyncSubject(); 95 | var input2 = Observable.Defer(() => 96 | { 97 | subscribeCount2++; 98 | return input2Subj; 99 | }); 100 | 101 | var fixture = new OperationQueue(2); 102 | 103 | // Block up the queue 104 | foreach (var v in new[] { subj1, subj2, }) 105 | { 106 | fixture.EnqueueObservableOperation(5, () => v); 107 | } 108 | 109 | // subj1,2 are live, input1,2 are in queue 110 | fixture 111 | .EnqueueObservableOperation(5, "key", Observable.Never(), () => input1) 112 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 113 | .Bind(out var out1).Subscribe(); 114 | fixture 115 | .EnqueueObservableOperation(5, "key", Observable.Never(), () => input2) 116 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 117 | .Bind(out var out2).Subscribe(); 118 | 119 | Assert.Equal(0, subscribeCount1); 120 | Assert.Equal(0, subscribeCount2); 121 | 122 | // Dispatch both subj1 and subj2, we should end up with input1 live, 123 | // but input2 in queue because of the key 124 | subj1.OnNext(42); 125 | subj1.OnCompleted(); 126 | subj2.OnNext(42); 127 | subj2.OnCompleted(); 128 | Assert.Equal(1, subscribeCount1); 129 | Assert.Equal(0, subscribeCount2); 130 | Assert.Empty(out1); 131 | Assert.Empty(out2); 132 | 133 | // Dispatch input1, input2 can now execute 134 | input1Subj.OnNext(42); 135 | input1Subj.OnCompleted(); 136 | Assert.Equal(1, subscribeCount1); 137 | Assert.Equal(1, subscribeCount2); 138 | Assert.Single(out1); 139 | Assert.Empty(out2); 140 | 141 | // Dispatch input2, everything is finished 142 | input2Subj.OnNext(42); 143 | input2Subj.OnCompleted(); 144 | Assert.Equal(1, subscribeCount1); 145 | Assert.Equal(1, subscribeCount2); 146 | Assert.Single(out1); 147 | Assert.Single(out2); 148 | } 149 | 150 | /// 151 | /// Checks to make sure that non key items are run in parallel. 152 | /// 153 | [Fact] 154 | public void NonkeyedItemsShouldRunInParallel() 155 | { 156 | var unkeyed1Subj = new AsyncSubject(); 157 | var unkeyed1SubCount = 0; 158 | var unkeyed1 = Observable.Defer(() => 159 | { 160 | unkeyed1SubCount++; 161 | return unkeyed1Subj; 162 | }); 163 | 164 | var unkeyed2Subj = new AsyncSubject(); 165 | var unkeyed2SubCount = 0; 166 | var unkeyed2 = Observable.Defer(() => 167 | { 168 | unkeyed2SubCount++; 169 | return unkeyed2Subj; 170 | }); 171 | 172 | var fixture = new OperationQueue(2); 173 | Assert.Equal(0, unkeyed1SubCount); 174 | Assert.Equal(0, unkeyed2SubCount); 175 | 176 | fixture.EnqueueObservableOperation(5, () => unkeyed1); 177 | fixture.EnqueueObservableOperation(5, () => unkeyed2); 178 | Assert.Equal(1, unkeyed1SubCount); 179 | Assert.Equal(1, unkeyed2SubCount); 180 | } 181 | 182 | /// 183 | /// Checks to make sure that shutdown signals once everything completes. 184 | /// 185 | [Fact] 186 | public void ShutdownShouldSignalOnceEverythingCompletes() 187 | { 188 | var subjects = Enumerable.Range(0, 5).Select(x => new AsyncSubject()).ToArray(); 189 | var priorities = new[] { 5, 5, 5, 10, 1, }; 190 | var fixture = new OperationQueue(2); 191 | 192 | // The two at the front are solely to stop up the queue, they get subscribed 193 | // to immediately. 194 | var outputs = subjects.Zip( 195 | priorities, 196 | (inp, pri) => 197 | { 198 | fixture 199 | .EnqueueObservableOperation(pri, () => inp) 200 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 201 | .Bind(out var output).Subscribe(); 202 | return output; 203 | }).ToArray(); 204 | 205 | fixture 206 | .ShutdownQueue() 207 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 208 | .Bind(out var shutdown).Subscribe(); 209 | 210 | Assert.True(outputs.All(x => x.Count == 0)); 211 | Assert.Empty(shutdown); 212 | 213 | for (int i = 0; i < 4; i++) 214 | { 215 | subjects[i].OnNext(42); 216 | subjects[i].OnCompleted(); 217 | } 218 | 219 | Assert.Empty(shutdown); 220 | 221 | // Complete the last one, that should signal that we're shut down 222 | subjects[4].OnNext(42); 223 | subjects[4].OnCompleted(); 224 | Assert.True(outputs.All(x => x.Count == 1)); 225 | Assert.Single(shutdown); 226 | } 227 | 228 | /// 229 | /// Checks to make sure that the queue holds items until unpaused. 230 | /// 231 | [Fact] 232 | public void PausingTheQueueShouldHoldItemsUntilUnpaused() 233 | { 234 | var item = Observable.Return(42); 235 | 236 | var fixture = new OperationQueue(2); 237 | new[] 238 | { 239 | fixture.EnqueueObservableOperation(4, () => item), 240 | fixture.EnqueueObservableOperation(4, () => item), 241 | }.Merge() 242 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 243 | .Bind(out var prePauseOutput).Subscribe(); 244 | 245 | Assert.Equal(2, prePauseOutput.Count); 246 | 247 | var unpause1 = fixture.PauseQueue(); 248 | 249 | // The queue is halted, but we should still eventually process these 250 | // once it's no longer halted 251 | new[] 252 | { 253 | fixture.EnqueueObservableOperation(4, () => item), 254 | fixture.EnqueueObservableOperation(4, () => item), 255 | }.Merge() 256 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 257 | .Bind(out var pauseOutput).Subscribe(); 258 | 259 | Assert.Empty(pauseOutput); 260 | 261 | var unpause2 = fixture.PauseQueue(); 262 | Assert.Empty(pauseOutput); 263 | 264 | unpause1.Dispose(); 265 | Assert.Empty(pauseOutput); 266 | 267 | unpause2.Dispose(); 268 | Assert.Equal(2, pauseOutput.Count); 269 | } 270 | 271 | /// 272 | /// Checks that cancelling items should not result in them being returned. 273 | /// 274 | [Fact] 275 | public void CancellingItemsShouldNotResultInThemBeingReturned() 276 | { 277 | var subj1 = new AsyncSubject(); 278 | var subj2 = new AsyncSubject(); 279 | 280 | var fixture = new OperationQueue(2); 281 | 282 | // Block up the queue 283 | foreach (var v in new[] { subj1, subj2, }) 284 | { 285 | fixture.EnqueueObservableOperation(5, () => v); 286 | } 287 | 288 | var cancel1 = new Subject(); 289 | var item1 = new AsyncSubject(); 290 | new[] 291 | { 292 | fixture.EnqueueObservableOperation(5, "foo", cancel1, () => item1), 293 | fixture.EnqueueObservableOperation(5, "baz", () => Observable.Return(42)), 294 | }.Merge() 295 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 296 | .Bind(out var output).Subscribe(); 297 | 298 | // Still blocked by subj1,2 299 | Assert.Empty(output); 300 | 301 | // Still blocked by subj1,2, only baz is in queue 302 | cancel1.OnNext(Unit.Default); 303 | cancel1.OnCompleted(); 304 | Assert.Empty(output); 305 | 306 | // foo was cancelled, baz is still good 307 | subj1.OnNext(42); 308 | subj1.OnCompleted(); 309 | Assert.Single(output); 310 | 311 | // don't care that cancelled item finished 312 | item1.OnNext(42); 313 | item1.OnCompleted(); 314 | Assert.Single(output); 315 | 316 | // still shouldn't see anything 317 | subj2.OnNext(42); 318 | subj2.OnCompleted(); 319 | Assert.Single(output); 320 | } 321 | 322 | /// 323 | /// Checks that the cancelling of items, that the items won't be evaluated. 324 | /// 325 | [Fact] 326 | public void CancellingItemsShouldntEvenBeEvaluated() 327 | { 328 | var subj1 = new AsyncSubject(); 329 | var subj2 = new AsyncSubject(); 330 | 331 | var fixture = new OperationQueue(2); 332 | 333 | // Block up the queue 334 | foreach (var v in new[] { subj1, subj2, }) 335 | { 336 | fixture.EnqueueObservableOperation(5, () => v); 337 | } 338 | 339 | var cancel1 = new Subject(); 340 | bool wasCalled = false; 341 | var item1 = new AsyncSubject(); 342 | 343 | fixture.EnqueueObservableOperation(5, "foo", cancel1, () => 344 | { 345 | wasCalled = true; 346 | return item1; 347 | }).ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 348 | .Bind(out var output).Subscribe(); 349 | 350 | // Still blocked by subj1,2 351 | Assert.Empty(output); 352 | Assert.False(wasCalled); 353 | 354 | // Still blocked by subj1,2 - however, we've cancelled foo before 355 | // it even had a chance to run - if that's the case, we shouldn't 356 | // even call the evaluation func 357 | cancel1.OnNext(Unit.Default); 358 | cancel1.OnCompleted(); 359 | Assert.Empty(output); 360 | Assert.False(wasCalled); 361 | 362 | // Unblock subj1,2, we still shouldn't see wasCalled = true 363 | subj1.OnNext(42); 364 | subj1.OnCompleted(); 365 | Assert.Empty(output); 366 | Assert.False(wasCalled); 367 | 368 | subj2.OnNext(42); 369 | subj2.OnCompleted(); 370 | Assert.Empty(output); 371 | Assert.False(wasCalled); 372 | } 373 | 374 | /// 375 | /// Checks to make sure the queue respects maximum concurrency. 376 | /// 377 | [Fact] 378 | public void QueueShouldRespectMaximumConcurrent() 379 | { 380 | var unkeyed1Subj = new AsyncSubject(); 381 | var unkeyed1SubCount = 0; 382 | var unkeyed1 = Observable.Defer(() => 383 | { 384 | unkeyed1SubCount++; 385 | return unkeyed1Subj; 386 | }); 387 | 388 | var unkeyed2Subj = new AsyncSubject(); 389 | var unkeyed2SubCount = 0; 390 | var unkeyed2 = Observable.Defer(() => 391 | { 392 | unkeyed2SubCount++; 393 | return unkeyed2Subj; 394 | }); 395 | 396 | var unkeyed3Subj = new AsyncSubject(); 397 | var unkeyed3SubCount = 0; 398 | var unkeyed3 = Observable.Defer(() => 399 | { 400 | unkeyed3SubCount++; 401 | return unkeyed3Subj; 402 | }); 403 | 404 | var fixture = new OperationQueue(2); 405 | Assert.Equal(0, unkeyed1SubCount); 406 | Assert.Equal(0, unkeyed2SubCount); 407 | Assert.Equal(0, unkeyed3SubCount); 408 | 409 | fixture.EnqueueObservableOperation(5, () => unkeyed1); 410 | fixture.EnqueueObservableOperation(5, () => unkeyed2); 411 | fixture.EnqueueObservableOperation(5, () => unkeyed3); 412 | 413 | Assert.Equal(1, unkeyed1SubCount); 414 | Assert.Equal(1, unkeyed2SubCount); 415 | Assert.Equal(0, unkeyed3SubCount); 416 | } 417 | 418 | /// 419 | /// Checks to see if the maximum concurrency is increased that the existing queue adapts. 420 | /// 421 | [Fact] 422 | public void ShouldBeAbleToIncreaseTheMaximunConcurrentValueOfAnExistingQueue() 423 | { 424 | var unkeyed1Subj = new AsyncSubject(); 425 | var unkeyed1SubCount = 0; 426 | var unkeyed1 = Observable.Defer(() => 427 | { 428 | unkeyed1SubCount++; 429 | return unkeyed1Subj; 430 | }); 431 | 432 | var unkeyed2Subj = new AsyncSubject(); 433 | var unkeyed2SubCount = 0; 434 | var unkeyed2 = Observable.Defer(() => 435 | { 436 | unkeyed2SubCount++; 437 | return unkeyed2Subj; 438 | }); 439 | 440 | var unkeyed3Subj = new AsyncSubject(); 441 | var unkeyed3SubCount = 0; 442 | var unkeyed3 = Observable.Defer(() => 443 | { 444 | unkeyed3SubCount++; 445 | return unkeyed3Subj; 446 | }); 447 | 448 | var unkeyed4Subj = new AsyncSubject(); 449 | var unkeyed4SubCount = 0; 450 | var unkeyed4 = Observable.Defer(() => 451 | { 452 | unkeyed4SubCount++; 453 | return unkeyed4Subj; 454 | }); 455 | 456 | var fixture = new OperationQueue(2); 457 | Assert.Equal(0, unkeyed1SubCount); 458 | Assert.Equal(0, unkeyed2SubCount); 459 | Assert.Equal(0, unkeyed3SubCount); 460 | Assert.Equal(0, unkeyed4SubCount); 461 | 462 | fixture.EnqueueObservableOperation(5, () => unkeyed1); 463 | fixture.EnqueueObservableOperation(5, () => unkeyed2); 464 | fixture.EnqueueObservableOperation(5, () => unkeyed3); 465 | fixture.EnqueueObservableOperation(5, () => unkeyed4); 466 | 467 | Assert.Equal(1, unkeyed1SubCount); 468 | Assert.Equal(1, unkeyed2SubCount); 469 | Assert.Equal(0, unkeyed3SubCount); 470 | Assert.Equal(0, unkeyed4SubCount); 471 | 472 | fixture.SetMaximumConcurrent(3); 473 | 474 | Assert.Equal(1, unkeyed1SubCount); 475 | Assert.Equal(1, unkeyed2SubCount); 476 | Assert.Equal(1, unkeyed3SubCount); 477 | Assert.Equal(0, unkeyed4SubCount); 478 | } 479 | 480 | /// 481 | /// Checks to make sure that decreasing the maximum concurrency the queue adapts. 482 | /// 483 | [Fact] 484 | public void ShouldBeAbleToDecreaseTheMaximunConcurrentValueOfAnExistingQueue() 485 | { 486 | var subjects = Enumerable.Range(0, 6).Select(x => new AsyncSubject()).ToArray(); 487 | var fixture = new OperationQueue(3); 488 | 489 | // The three at the front are solely to stop up the queue, they get subscribed 490 | // to immediately. 491 | var outputs = subjects 492 | .Select(inp => 493 | { 494 | fixture 495 | .EnqueueObservableOperation(5, () => inp) 496 | .ToObservableChangeSet(scheduler: ImmediateScheduler.Instance) 497 | .Bind(out var output).Subscribe(); 498 | return output; 499 | }).ToArray(); 500 | 501 | Assert.True( 502 | new[] { true, true, true, false, false, false, }.Zip( 503 | subjects, 504 | (expected, subj) => new { expected, actual = subj.HasObservers, }) 505 | .All(x => x.expected == x.actual)); 506 | 507 | fixture.SetMaximumConcurrent(2); 508 | 509 | // Complete the first one, the last three subjects should still have 510 | // no observers because we reduced maximum concurrent 511 | subjects[0].OnNext(42); 512 | subjects[0].OnCompleted(); 513 | 514 | Assert.True( 515 | new[] { false, true, true, false, false, false, }.Zip( 516 | subjects, 517 | (expected, subj) => new { expected, actual = subj.HasObservers, }) 518 | .All(x => x.expected == x.actual)); 519 | 520 | // Complete subj[1], now 2,3 are live 521 | subjects[1].OnNext(42); 522 | subjects[1].OnCompleted(); 523 | 524 | Assert.True( 525 | new[] { false, false, true, true, false, false, }.Zip( 526 | subjects, 527 | (expected, subj) => new { expected, actual = subj.HasObservers, }) 528 | .All(x => x.expected == x.actual)); 529 | } 530 | } 531 | } 532 | -------------------------------------------------------------------------------- /src/Punchclock.Tests/Punchclock.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0;net8.0 4 | $(TargetFrameworks);net472 5 | $(NoWarn);1591;CA1707;SA1633 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Punchclock.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # 17 3 | VisualStudioVersion = 17.3.32922.545 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Punchclock", "Punchclock\Punchclock.csproj", "{D3D5E08E-2DAA-4C14-BDF1-C15BD81247F5}" 6 | EndProject 7 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Punchclock.Tests", "Punchclock.Tests\Punchclock.Tests.csproj", "{1966B9AC-F962-4AAD-9B17-E581D12619A1}" 8 | EndProject 9 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Items", "Items", "{E035091F-9C2B-4F6D-924F-08EC78AB9E43}" 10 | ProjectSection(SolutionItems) = preProject 11 | analyzers.ruleset = analyzers.ruleset 12 | analyzers.tests.ruleset = analyzers.tests.ruleset 13 | Directory.build.props = Directory.build.props 14 | ..\README.md = ..\README.md 15 | stylecop.json = stylecop.json 16 | ..\version.json = ..\version.json 17 | EndProjectSection 18 | EndProject 19 | Global 20 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 21 | Debug|Any CPU = Debug|Any CPU 22 | Release|Any CPU = Release|Any CPU 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {D3D5E08E-2DAA-4C14-BDF1-C15BD81247F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {D3D5E08E-2DAA-4C14-BDF1-C15BD81247F5}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {D3D5E08E-2DAA-4C14-BDF1-C15BD81247F5}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {D3D5E08E-2DAA-4C14-BDF1-C15BD81247F5}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {1966B9AC-F962-4AAD-9B17-E581D12619A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {1966B9AC-F962-4AAD-9B17-E581D12619A1}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {1966B9AC-F962-4AAD-9B17-E581D12619A1}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {1966B9AC-F962-4AAD-9B17-E581D12619A1}.Release|Any CPU.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | GlobalSection(SolutionProperties) = preSolution 35 | HideSolutionNode = FALSE 36 | EndGlobalSection 37 | GlobalSection(ExtensibilityGlobals) = postSolution 38 | SolutionGuid = {5CDD70E5-DB77-4301-8EB4-706CCACF4459} 39 | EndGlobalSection 40 | EndGlobal 41 | -------------------------------------------------------------------------------- /src/Punchclock/KeyedOperation.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Diagnostics.CodeAnalysis; 8 | using System.Reactive; 9 | using System.Reactive.Linq; 10 | using System.Reactive.Subjects; 11 | 12 | namespace Punchclock; 13 | 14 | internal abstract class KeyedOperation : IComparable 15 | { 16 | public bool CancelledEarly { get; set; } 17 | 18 | public int Priority { get; set; } 19 | 20 | public int Id { get; set; } 21 | 22 | public string? Key { get; set; } 23 | 24 | public IObservable? CancelSignal { get; set; } 25 | 26 | public bool KeyIsDefault => string.IsNullOrEmpty(Key) || Key == OperationQueue.DefaultKey; 27 | 28 | public abstract IObservable EvaluateFunc(); 29 | 30 | public int CompareTo(KeyedOperation other) 31 | { 32 | // NB: Non-keyed operations always come before keyed operations in 33 | // order to make sure that serialized keyed operations don't take 34 | // up concurrency slots 35 | if (KeyIsDefault != other.KeyIsDefault) 36 | { 37 | return KeyIsDefault ? 1 : -1; 38 | } 39 | 40 | return other.Priority.CompareTo(Priority); 41 | } 42 | } 43 | 44 | [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Generic implementation of same class name.")] 45 | internal class KeyedOperation : KeyedOperation 46 | { 47 | public Func>? Func { get; set; } 48 | 49 | public ReplaySubject Result { get; } = new ReplaySubject(); 50 | 51 | public override IObservable EvaluateFunc() 52 | { 53 | if (Func == null) 54 | { 55 | return Observable.Empty(); 56 | } 57 | 58 | if (CancelledEarly) 59 | { 60 | return Observable.Empty(); 61 | } 62 | 63 | var signal = CancelSignal ?? Observable.Empty(); 64 | var ret = Func().TakeUntil(signal).Multicast(Result); 65 | ret.Connect(); 66 | 67 | return ret.Select(_ => Unit.Default); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Punchclock/OperationQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Diagnostics; 8 | using System.Reactive; 9 | using System.Reactive.Concurrency; 10 | using System.Reactive.Disposables; 11 | using System.Reactive.Linq; 12 | using System.Reactive.Subjects; 13 | using System.Threading; 14 | 15 | namespace Punchclock; 16 | 17 | /// 18 | /// OperationQueue is the core of PunchClock, and represents a scheduler for 19 | /// deferred actions, such as network requests. This scheduler supports 20 | /// scheduling via priorities, as well as serializing requests that access 21 | /// the same data. 22 | /// 23 | /// The queue allows a fixed number of concurrent in-flight operations at a 24 | /// time. When there are available "slots", items are dispatched as they come 25 | /// in. When the slots are full, the queueing policy starts to apply. 26 | /// 27 | /// The queue, similar to Akavache's KeyedOperationQueue, also allows keys to 28 | /// be specified to serialize operations - if you have three "foo" items, they 29 | /// will wait in line and only one "foo" can run. However, a "bar" and "baz" 30 | /// item can run at the same time as a "foo" item. 31 | /// 32 | public class OperationQueue : IDisposable 33 | { 34 | private static int sequenceNumber; 35 | 36 | private readonly Subject _queuedOps = new(); 37 | private readonly IConnectableObservable _resultObs; 38 | private readonly PrioritySemaphoreSubject _scheduledGate; 39 | private int _maximumConcurrent; 40 | private int _pauseRefCount; 41 | private bool _isDisposed; 42 | 43 | private AsyncSubject? _shutdownObs; 44 | 45 | /// 46 | /// Initializes a new instance of the class. 47 | /// 48 | /// The maximum number of concurrent operations. 49 | public OperationQueue(int maximumConcurrent = 4) 50 | { 51 | _maximumConcurrent = maximumConcurrent; 52 | _scheduledGate = new(maximumConcurrent); 53 | 54 | _resultObs = _queuedOps 55 | .Multicast(_scheduledGate).RefCount() 56 | .GroupBy(x => x.Key) 57 | .Select(x => 58 | { 59 | var ret = x.Select( 60 | y => ProcessOperation(y) 61 | .TakeUntil(y.CancelSignal ?? Observable.Empty()) 62 | .Finally(() => _scheduledGate.Release())); 63 | return x.Key == DefaultKey ? ret.Merge() : ret.Concat(); 64 | }) 65 | .Merge() 66 | .Multicast(new Subject()); 67 | 68 | _resultObs.Connect(); 69 | } 70 | 71 | /// 72 | /// Gets the default key used if there is no item. 73 | /// 74 | internal static string DefaultKey { get; } = "__NONE__"; 75 | 76 | /// 77 | /// This method enqueues an action to be run at a later time, according 78 | /// to the scheduling policies (i.e. via priority and key). 79 | /// 80 | /// The type of item for the observable. 81 | /// Used to allow any observable type. 82 | /// The priority of operation. Higher priorities run before lower ones. 83 | /// A key to apply to the operation. Items with the same key will be run in order. 84 | /// A observable which if signalled, the operation will be cancelled. 85 | /// The async method to execute when scheduled. 86 | /// The result of the async calculation. 87 | public IObservable EnqueueObservableOperation(int priority, string key, IObservable cancel, Func> asyncCalculationFunc) 88 | { 89 | var id = Interlocked.Increment(ref sequenceNumber); 90 | var cancelReplay = new ReplaySubject(); 91 | 92 | var item = new KeyedOperation 93 | { 94 | Key = key ?? DefaultKey, 95 | Id = id, 96 | Priority = priority, 97 | CancelSignal = cancelReplay.Select(_ => Unit.Default), 98 | Func = asyncCalculationFunc, 99 | }; 100 | 101 | cancel 102 | .Do(_ => 103 | { 104 | Debug.WriteLine("Cancelling {0}", id); 105 | item.CancelledEarly = true; 106 | }) 107 | .Multicast(cancelReplay).Connect(); 108 | 109 | lock (_queuedOps) 110 | { 111 | Debug.WriteLine("Queued item {0}, priority {1}", item.Id, item.Priority); 112 | _queuedOps.OnNext(item); 113 | } 114 | 115 | return item.Result; 116 | } 117 | 118 | /// 119 | /// This method enqueues an action to be run at a later time, according 120 | /// to the scheduling policies (i.e. via priority and key). 121 | /// 122 | /// The type of item for the observable. 123 | /// Higher priorities run before lower ones. 124 | /// Items with the same key will be run in order. 125 | /// The async method to execute when scheduled. 126 | /// The result of the async calculation. 127 | public IObservable EnqueueObservableOperation(int priority, string key, Func> asyncCalculationFunc) => 128 | EnqueueObservableOperation(priority, key, Observable.Never(), asyncCalculationFunc); 129 | 130 | /// 131 | /// This method enqueues an action to be run at a later time, according 132 | /// to the scheduling policies (i.e. via priority). 133 | /// 134 | /// The type of item for the observable. 135 | /// Higher priorities run before lower ones. 136 | /// The async method to execute when scheduled. 137 | /// The result of the async calculation. 138 | public IObservable EnqueueObservableOperation(int priority, Func> asyncCalculationFunc) => 139 | EnqueueObservableOperation(priority, DefaultKey, Observable.Never(), asyncCalculationFunc); 140 | 141 | /// 142 | /// This method pauses the dispatch queue. Inflight operations will not 143 | /// be canceled, but new ones will not be processed until the queue is 144 | /// resumed. 145 | /// 146 | /// A Disposable that resumes the queue when disposed. 147 | public IDisposable PauseQueue() 148 | { 149 | if (Interlocked.Increment(ref _pauseRefCount) == 1) 150 | { 151 | _scheduledGate.MaximumCount = 0; 152 | } 153 | 154 | return Disposable.Create(() => 155 | { 156 | if (Interlocked.Decrement(ref _pauseRefCount) > 0) 157 | { 158 | return; 159 | } 160 | 161 | if (_shutdownObs != null) 162 | { 163 | return; 164 | } 165 | 166 | _scheduledGate.MaximumCount = _maximumConcurrent; 167 | }); 168 | } 169 | 170 | /// 171 | /// Sets the maximum level of concurrency for the operation queue. 172 | /// 173 | /// The maximum amount of concurrency. 174 | public void SetMaximumConcurrent(int maximumConcurrent) 175 | { 176 | using (PauseQueue()) 177 | { 178 | _maximumConcurrent = maximumConcurrent; 179 | } 180 | } 181 | 182 | /// 183 | /// Shuts down the queue and notifies when all outstanding items have 184 | /// been processed. 185 | /// 186 | /// An Observable that will signal when all items are complete. 187 | /// 188 | public IObservable ShutdownQueue() 189 | { 190 | lock (_queuedOps) 191 | { 192 | if (_shutdownObs != null) 193 | { 194 | return _shutdownObs; 195 | } 196 | 197 | _shutdownObs = new AsyncSubject(); 198 | 199 | // Disregard paused queue 200 | _scheduledGate.MaximumCount = _maximumConcurrent; 201 | 202 | _queuedOps.OnCompleted(); 203 | 204 | _resultObs.Materialize() 205 | .Where(x => x.Kind != NotificationKind.OnNext) 206 | .SelectMany(x => 207 | x.Kind == NotificationKind.OnError ? 208 | Observable.Throw(x.Exception!) : 209 | Observable.Return(Unit.Default)) 210 | .Multicast(_shutdownObs) 211 | .Connect(); 212 | 213 | return _shutdownObs; 214 | } 215 | } 216 | 217 | /// 218 | public void Dispose() 219 | { 220 | Dispose(true); 221 | GC.SuppressFinalize(this); 222 | } 223 | 224 | /// 225 | /// Disposes managed resources that are disposable and handles cleanup of unmanaged items. 226 | /// 227 | /// If we are disposing managed resources. 228 | protected virtual void Dispose(bool isDisposing) 229 | { 230 | if (_isDisposed) 231 | { 232 | return; 233 | } 234 | 235 | if (isDisposing) 236 | { 237 | _queuedOps?.Dispose(); 238 | _shutdownObs?.Dispose(); 239 | } 240 | 241 | _isDisposed = true; 242 | } 243 | 244 | private static IObservable ProcessOperation(KeyedOperation operation) 245 | { 246 | Debug.WriteLine("Processing item {0}, priority {1}", operation.Id, operation.Priority); 247 | return Observable.Defer(operation.EvaluateFunc) 248 | .Select(_ => operation) 249 | .Catch(Observable.Return(operation)); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/Punchclock/OperationQueueExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Reactive; 8 | using System.Reactive.Linq; 9 | using System.Reactive.Subjects; 10 | using System.Reactive.Threading.Tasks; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | 14 | namespace Punchclock; 15 | 16 | /// 17 | /// Extension methods associated with the . 18 | /// 19 | public static class OperationQueueExtensions 20 | { 21 | /// 22 | /// Adds a operation to the operation queue. 23 | /// 24 | /// The type of item contained within our observable. 25 | /// The operation queue to add our operation to. 26 | /// The priority of operation. Higher priorities run before lower ones. 27 | /// A key to apply to the operation. Items with the same key will be run in order. 28 | /// The async method to execute when scheduled. 29 | /// A cancellation token which if signalled, the operation will be cancelled. 30 | /// A task to monitor the progress. 31 | public static Task Enqueue(this OperationQueue operationQueue, int priority, string key, Func> asyncOperation, CancellationToken token) 32 | { 33 | if (operationQueue == null) 34 | { 35 | throw new ArgumentNullException(nameof(operationQueue)); 36 | } 37 | 38 | return operationQueue.EnqueueObservableOperation(priority, key, ConvertTokenToObservable(token), () => asyncOperation().ToObservable()) 39 | .ToTask(token); 40 | } 41 | 42 | /// 43 | /// Adds a operation to the operation queue. 44 | /// 45 | /// The operation queue to add our operation to. 46 | /// The priority of operation. Higher priorities run before lower ones. 47 | /// A key to apply to the operation. Items with the same key will be run in order. 48 | /// The async method to execute when scheduled. 49 | /// A cancellation token which if signalled, the operation will be cancelled. 50 | /// A task to monitor the progress. 51 | public static Task Enqueue(this OperationQueue operationQueue, int priority, string key, Func asyncOperation, CancellationToken token) 52 | { 53 | if (operationQueue == null) 54 | { 55 | throw new ArgumentNullException(nameof(operationQueue)); 56 | } 57 | 58 | return operationQueue.EnqueueObservableOperation(priority, key, ConvertTokenToObservable(token), () => asyncOperation().ToObservable()) 59 | .ToTask(token); 60 | } 61 | 62 | /// 63 | /// Adds a operation to the operation queue. 64 | /// 65 | /// The type of item contained within our observable. 66 | /// The operation queue to add our operation to. 67 | /// The priority of operation. Higher priorities run before lower ones. 68 | /// A key to apply to the operation. Items with the same key will be run in order. 69 | /// The async method to execute when scheduled. 70 | /// A task to monitor the progress. 71 | public static Task Enqueue(this OperationQueue operationQueue, int priority, string key, Func> asyncOperation) 72 | { 73 | if (operationQueue == null) 74 | { 75 | throw new ArgumentNullException(nameof(operationQueue)); 76 | } 77 | 78 | return operationQueue.EnqueueObservableOperation(priority, key, Observable.Never(), () => asyncOperation().ToObservable()) 79 | .ToTask(); 80 | } 81 | 82 | /// 83 | /// Adds a operation to the operation queue. 84 | /// 85 | /// The operation queue to add our operation to. 86 | /// The priority of operation. Higher priorities run before lower ones. 87 | /// A key to apply to the operation. Items with the same key will be run in order. 88 | /// The async method to execute when scheduled. 89 | /// A task to monitor the progress. 90 | public static Task Enqueue(this OperationQueue operationQueue, int priority, string key, Func asyncOperation) 91 | { 92 | if (operationQueue == null) 93 | { 94 | throw new ArgumentNullException(nameof(operationQueue)); 95 | } 96 | 97 | return operationQueue.EnqueueObservableOperation(priority, key, Observable.Never(), () => asyncOperation().ToObservable()) 98 | .ToTask(); 99 | } 100 | 101 | /// 102 | /// Adds a operation to the operation queue. 103 | /// 104 | /// The type of item contained within our observable. 105 | /// The operation queue to add our operation to. 106 | /// The priority of operation. Higher priorities run before lower ones. 107 | /// The async method to execute when scheduled. 108 | /// A task to monitor the progress. 109 | public static Task Enqueue(this OperationQueue operationQueue, int priority, Func> asyncOperation) 110 | { 111 | if (operationQueue == null) 112 | { 113 | throw new ArgumentNullException(nameof(operationQueue)); 114 | } 115 | 116 | return operationQueue.EnqueueObservableOperation(priority, () => asyncOperation().ToObservable()) 117 | .ToTask(); 118 | } 119 | 120 | /// 121 | /// Adds a operation to the operation queue. 122 | /// 123 | /// The operation queue to add our operation to. 124 | /// The priority of operation. Higher priorities run before lower ones. 125 | /// The async method to execute when scheduled. 126 | /// A task to monitor the progress. 127 | public static Task Enqueue(this OperationQueue operationQueue, int priority, Func asyncOperation) 128 | { 129 | if (operationQueue == null) 130 | { 131 | throw new ArgumentNullException(nameof(operationQueue)); 132 | } 133 | 134 | return operationQueue.EnqueueObservableOperation(priority, () => asyncOperation().ToObservable()) 135 | .ToTask(); 136 | } 137 | 138 | private static IObservable ConvertTokenToObservable(CancellationToken token) 139 | { 140 | var cancel = new AsyncSubject(); 141 | 142 | if (token.IsCancellationRequested) 143 | { 144 | return Observable.Throw(new ArgumentException("Token is already cancelled")); 145 | } 146 | 147 | token.Register(() => 148 | { 149 | cancel.OnNext(Unit.Default); 150 | cancel.OnCompleted(); 151 | }); 152 | return cancel; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Punchclock/PriorityQueue.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Threading; 9 | 10 | namespace Punchclock; 11 | 12 | /// 13 | /// A priority queue which will store items contained in order of the various priorities. 14 | /// 15 | /// The type of item to store in the queue. 16 | /// 17 | /// Based off Microsoft internal code. 18 | /// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. 19 | /// This is https://github.com/mono/rx/blob/master/Rx/NET/Source/System.Reactive.Core/Reactive/Internal/PriorityQueue.cs originally. 20 | /// 21 | /// 22 | /// Initializes a new instance of the class. 23 | /// 24 | /// The starting capacity of the queue. 25 | internal class PriorityQueue(int capacity) 26 | where T : IComparable 27 | { 28 | private const int DefaultCapacity = 16; 29 | 30 | #if !NO_INTERLOCKED_64 31 | private static long _count = long.MinValue; 32 | #else 33 | private static int _count = int.MinValue; 34 | #endif 35 | private IndexedItem[] _items = new IndexedItem[capacity]; 36 | 37 | /// 38 | /// Initializes a new instance of the class. 39 | /// 40 | public PriorityQueue() 41 | : this(DefaultCapacity) 42 | { 43 | } 44 | 45 | /// 46 | /// Gets the number of items inside the queue. 47 | /// 48 | public int Count { get; private set; } 49 | 50 | /// 51 | /// Peeks at the next time available in the queue. 52 | /// 53 | /// The next item. 54 | public T Peek() 55 | { 56 | if (Count == 0) 57 | { 58 | throw new InvalidOperationException("There are no items in the collection"); 59 | } 60 | 61 | return _items[0].Value; 62 | } 63 | 64 | /// 65 | /// Removes and returns the next item in the queue. 66 | /// 67 | /// The next item. 68 | public T Dequeue() 69 | { 70 | var result = Peek(); 71 | RemoveAt(0, true); 72 | return result; 73 | } 74 | 75 | /// 76 | /// Removes up to the specified number of items and returns those items. 77 | /// 78 | /// The maximum number of items to remove from the queue. 79 | /// The next items. 80 | public T[] DequeueSome(int count) 81 | { 82 | if (count == 0) 83 | { 84 | return []; 85 | } 86 | 87 | var ret = new T[count]; 88 | count = Math.Min(count, Count); 89 | for (int i = 0; i < count; i++) 90 | { 91 | ret[i] = Peek(); 92 | RemoveAt(0, false); 93 | } 94 | 95 | return ret; 96 | } 97 | 98 | /// 99 | /// Removes all the items currently contained within the queue and returns them. 100 | /// 101 | /// All the items from the queue. 102 | public T[] DequeueAll() => DequeueSome(Count); 103 | 104 | /// 105 | /// Adds a item in the correct location based on priority to the queue. 106 | /// 107 | /// The item to add. 108 | public void Enqueue(T item) 109 | { 110 | if (Count >= _items.Length) 111 | { 112 | var temp = _items; 113 | _items = new IndexedItem[_items.Length * 2]; 114 | Array.Copy(temp, _items, temp.Length); 115 | } 116 | 117 | var index = Count++; 118 | _items[index] = new IndexedItem { Value = item, Id = Interlocked.Increment(ref _count) }; 119 | Percolate(index); 120 | } 121 | 122 | /// 123 | /// Removes the specified item from the queue. 124 | /// 125 | /// The item to remove. 126 | /// If the remove was successful or not. 127 | public bool Remove(T item) 128 | { 129 | for (var i = 0; i < Count; ++i) 130 | { 131 | if (EqualityComparer.Default.Equals(_items[i].Value, item)) 132 | { 133 | RemoveAt(i, false); 134 | return true; 135 | } 136 | } 137 | 138 | return false; 139 | } 140 | 141 | private bool IsHigherPriority(int left, int right) => _items[left].CompareTo(_items[right]) < 0; 142 | 143 | private void Percolate(int index) 144 | { 145 | if (index >= Count || index < 0) 146 | { 147 | return; 148 | } 149 | 150 | var parent = (index - 1) / 2; 151 | if (parent < 0 || parent == index) 152 | { 153 | return; 154 | } 155 | 156 | if (IsHigherPriority(index, parent)) 157 | { 158 | (_items[parent], _items[index]) = (_items[index], _items[parent]); 159 | Percolate(parent); 160 | } 161 | } 162 | 163 | private void Heapify() => Heapify(0); 164 | 165 | private void Heapify(int index) 166 | { 167 | if (index >= Count || index < 0) 168 | { 169 | return; 170 | } 171 | 172 | var left = (2 * index) + 1; 173 | var right = (2 * index) + 2; 174 | var first = index; 175 | 176 | if (left < Count && IsHigherPriority(left, first)) 177 | { 178 | first = left; 179 | } 180 | 181 | if (right < Count && IsHigherPriority(right, first)) 182 | { 183 | first = right; 184 | } 185 | 186 | if (first != index) 187 | { 188 | (_items[first], _items[index]) = (_items[index], _items[first]); 189 | Heapify(first); 190 | } 191 | } 192 | 193 | private void RemoveAt(int index, bool single) 194 | { 195 | _items[index] = _items[--Count]; 196 | _items[Count] = default; 197 | Heapify(); 198 | if (Count < _items.Length / 4 && (single || Count < DefaultCapacity)) 199 | { 200 | var temp = _items; 201 | _items = new IndexedItem[_items.Length / 2]; 202 | Array.Copy(temp, 0, _items, 0, Count); 203 | } 204 | } 205 | 206 | private struct IndexedItem : IComparable 207 | { 208 | public T Value; 209 | #if !NO_INTERLOCKED_64 210 | public long Id; 211 | #else 212 | public int Id; 213 | #endif 214 | 215 | public int CompareTo(IndexedItem other) 216 | { 217 | var c = Value.CompareTo(other.Value); 218 | if (c == 0) 219 | { 220 | c = Id.CompareTo(other.Id); 221 | } 222 | 223 | return c; 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Punchclock/PrioritySemaphoreSubject.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Reactive.Concurrency; 8 | using System.Reactive.Subjects; 9 | using System.Threading; 10 | 11 | namespace Punchclock; 12 | 13 | internal class PrioritySemaphoreSubject : ISubject 14 | where T : IComparable 15 | { 16 | private readonly ISubject _inner; 17 | private PriorityQueue _nextItems = new(); 18 | private int _count; 19 | 20 | private int _MaximumCount; 21 | 22 | /// 23 | /// Initializes a new instance of the class. 24 | /// 25 | /// The maximum number of items to allow. 26 | /// The scheduler to use when emitting the items. 27 | public PrioritySemaphoreSubject(int maxCount, IScheduler? sched = null) 28 | { 29 | _inner = sched != null ? new ScheduledSubject(sched) : new Subject(); 30 | MaximumCount = maxCount; 31 | } 32 | 33 | /// 34 | /// Gets or sets the maximum count to allow. 35 | /// 36 | public int MaximumCount 37 | { 38 | get => _MaximumCount; 39 | set 40 | { 41 | _MaximumCount = value; 42 | YieldUntilEmptyOrBlocked(); 43 | } 44 | } 45 | 46 | /// 47 | public void OnNext(T value) 48 | { 49 | var queue = Interlocked.CompareExchange(ref _nextItems, null!, null!); 50 | if (queue == null) 51 | { 52 | return; 53 | } 54 | 55 | lock (queue) 56 | { 57 | queue.Enqueue(value); 58 | } 59 | 60 | YieldUntilEmptyOrBlocked(); 61 | } 62 | 63 | /// 64 | /// Releases a reference counted value. 65 | /// 66 | public void Release() 67 | { 68 | Interlocked.Decrement(ref _count); 69 | YieldUntilEmptyOrBlocked(); 70 | } 71 | 72 | /// 73 | public void OnCompleted() 74 | { 75 | var queue = Interlocked.Exchange(ref _nextItems, null!); 76 | if (queue == null) 77 | { 78 | return; 79 | } 80 | 81 | T[] items; 82 | lock (queue) 83 | { 84 | items = queue.DequeueAll(); 85 | } 86 | 87 | foreach (var v in items) 88 | { 89 | _inner.OnNext(v); 90 | } 91 | 92 | _inner.OnCompleted(); 93 | } 94 | 95 | /// 96 | public void OnError(Exception error) 97 | { 98 | Interlocked.Exchange(ref _nextItems, null!); 99 | _inner.OnError(error); 100 | } 101 | 102 | /// 103 | public IDisposable Subscribe(IObserver observer) => _inner.Subscribe(observer); 104 | 105 | private void YieldUntilEmptyOrBlocked() 106 | { 107 | var queue = Interlocked.CompareExchange(ref _nextItems, null!, null!); 108 | 109 | if (queue == null) 110 | { 111 | return; 112 | } 113 | 114 | while (_count < MaximumCount) 115 | { 116 | T next; 117 | lock (queue) 118 | { 119 | if (queue.Count == 0) 120 | { 121 | break; 122 | } 123 | 124 | next = queue.Dequeue(); 125 | } 126 | 127 | _inner.OnNext(next); 128 | 129 | if (Interlocked.Increment(ref _count) >= MaximumCount) 130 | { 131 | break; 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Punchclock/Punchclock.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0 4 | Punchclock 5 | enable 6 | CS8625;CS8604;CS8600;CS8614;CS8603;CS8618;CS8619 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Punchclock/ScheduledSubject.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 .NET Foundation and Contributors. All rights reserved. 2 | // Licensed to the .NET Foundation under one or more agreements. 3 | // The .NET Foundation licenses this file to you under the MIT license. 4 | // See the LICENSE file in the project root for full license information. 5 | 6 | using System; 7 | using System.Reactive.Concurrency; 8 | using System.Reactive.Disposables; 9 | using System.Reactive.Linq; 10 | using System.Reactive.Subjects; 11 | using System.Threading; 12 | 13 | namespace Punchclock; 14 | 15 | /// 16 | /// A subject which emits using the specified scheduler. 17 | /// 18 | /// The type of item to emit. 19 | internal class ScheduledSubject : ISubject, IDisposable 20 | { 21 | private readonly IObserver? _defaultObserver; 22 | private readonly IScheduler _scheduler; 23 | private readonly Subject _subject = new(); 24 | 25 | private int _observerRefCount; 26 | private IDisposable? _defaultObserverSub; 27 | private bool _isDisposed; 28 | 29 | /// 30 | /// Initializes a new instance of the class. 31 | /// 32 | /// The scheduler to emit items on. 33 | /// A default observable which will get values if no other subscribes. 34 | public ScheduledSubject(IScheduler scheduler, IObserver? defaultObserver = null) 35 | { 36 | _scheduler = scheduler; 37 | _defaultObserver = defaultObserver; 38 | 39 | if (defaultObserver != null) 40 | { 41 | _defaultObserverSub = _subject.ObserveOn(_scheduler).Subscribe(_defaultObserver); 42 | } 43 | } 44 | 45 | /// 46 | public void OnCompleted() => _subject.OnCompleted(); 47 | 48 | /// 49 | public void OnError(Exception error) => _subject.OnError(error); 50 | 51 | /// 52 | public void OnNext(T value) => _subject.OnNext(value); 53 | 54 | /// 55 | public IDisposable Subscribe(IObserver? observer) 56 | { 57 | if (_defaultObserverSub != null) 58 | { 59 | _defaultObserverSub.Dispose(); 60 | _defaultObserverSub = null; 61 | } 62 | 63 | Interlocked.Increment(ref _observerRefCount); 64 | 65 | return new CompositeDisposable( 66 | _subject.ObserveOn(_scheduler).Subscribe(observer), 67 | Disposable.Create(() => 68 | { 69 | if (Interlocked.Decrement(ref _observerRefCount) <= 0 && _defaultObserver != null) 70 | { 71 | _defaultObserverSub = _subject.ObserveOn(_scheduler).Subscribe(_defaultObserver); 72 | } 73 | })); 74 | } 75 | 76 | /// 77 | public void Dispose() 78 | { 79 | Dispose(true); 80 | GC.SuppressFinalize(this); 81 | } 82 | 83 | /// 84 | /// Disposes managed resources that are disposable and handles cleanup of unmanaged items. 85 | /// 86 | /// If we are disposing managed resources. 87 | protected virtual void Dispose(bool isDisposing) 88 | { 89 | if (_isDisposed) 90 | { 91 | return; 92 | } 93 | 94 | if (isDisposing) 95 | { 96 | _subject?.Dispose(); 97 | _defaultObserverSub?.Dispose(); 98 | } 99 | 100 | _isDisposed = true; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/analyzers.ruleset: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | -------------------------------------------------------------------------------- /src/stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 3 | "settings": { 4 | "indentation": { 5 | "useTabs": false, 6 | "indentationSize": 4 7 | }, 8 | "documentationRules": { 9 | "documentExposedElements": true, 10 | "documentInternalElements": false, 11 | "documentPrivateElements": false, 12 | "documentInterfaces": true, 13 | "documentPrivateFields": false, 14 | "documentationCulture": "en-US", 15 | "companyName": ".NET Foundation and Contributors", 16 | "copyrightText": "Copyright (c) 2024 {companyName}. All rights reserved.\nLicensed to the .NET Foundation under one or more agreements.\nThe .NET Foundation licenses this file to you under the {licenseName} license.\nSee the {licenseFile} file in the project root for full license information.", 17 | "variables": { 18 | "licenseName": "MIT", 19 | "licenseFile": "LICENSE" 20 | }, 21 | "xmlHeader": false 22 | }, 23 | "layoutRules": { 24 | "newlineAtEndOfFile": "allow", 25 | "allowConsecutiveUsings": true 26 | }, 27 | "maintainabilityRules": { 28 | "topLevelTypes": [ 29 | "class", 30 | "interface", 31 | "struct", 32 | "enum", 33 | "delegate" 34 | ] 35 | }, 36 | "orderingRules": { 37 | "usingDirectivesPlacement": "outsideNamespace", 38 | "systemUsingDirectivesFirst": true 39 | }, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.4", 3 | "publicReleaseRefSpec": [ 4 | "^refs/heads/main$", // we release out of master 5 | "^refs/heads/preview/.*", // we release previews 6 | "^refs/heads/rel/\\d+\\.\\d+\\.\\d+" // we also release branches starting with rel/N.N.N 7 | ], 8 | "nugetPackageVersion": { 9 | "semVer": 2 10 | }, 11 | "cloudBuild": { 12 | "setVersionVariables": true, 13 | "buildNumber": { 14 | "enabled": false 15 | } 16 | } 17 | } 18 | --------------------------------------------------------------------------------