[{"content":"A workflow gets difficult the moment it has to stop, ask a person something, and carry on later. The work is usually simple. The waiting is not, because the person may answer in ten seconds, tomorrow morning, or never.\nDurable orchestration exists for that shape. Instead of holding a half finished request in memory, or rebuilding it from a status column, the orchestrator records what has happened, releases the compute while it waits, and replays safely when something new arrives.\nThe example I keep coming back to #Document routing, from a service I worked on. A customer uploads a document, the service tries to file it automatically, and when it is not confident enough a reviewer picks a destination or rejects it. If nobody answers before the deadline, the case expires.\nNone of that is unusual, and the review screen is ordinary application code. The cost is in the resume. The first implementation of this grew a status column, a notification, an endpoint to accept the answer, a queue message, a worker that rebuilt context, and a scheduled job that found the cases nobody had answered. Each piece was reasonable on its own. Together they were the same workflow, spread across six places, and every crash meant asking which of them was now out of step.\nWhat was missing was a place to keep the fact that this workflow is waiting at a particular point, without pretending the whole process is still alive in memory.\nThe pieces #Durable Task SDKs give you that boundary. The orchestrator describes the order of decisions. A worker hosts the code that runs. The scheduler stores the state, the history and the timers.\nPart What it does Orchestrator Decides what happens next: steps, waits, timers, branches. Must be deterministic. Activity Does one real thing: an API call, a database write, a file, a notification. Client Starts an instance, raises an event, asks for status. Never executes the workflow. Task hub Keeps instance state, history, timers and messages. Worker Hosts the orchestrator and activity code. The orchestrator is the brain and activities are the hands. The brain picks the next step; the hands touch the outside world. Put anything with a side effect in an activity.\nflowchart LR C[\"API or event source\"] --\u003e|\"start, signal, query\"| H[(\"Durable task hub\")] H --\u003e|\"deliver work\"| W[\"Worker and orchestrator\"] W --\u003e|\"call\"| A[\"Activity with I/O\"] A --\u003e|\"record result\"| H That split is what lets the upload request return immediately after signalling the workflow. The reviewer answers hours later through a normal authenticated endpoint, and the scheduler makes sure the right instance wakes up.\nWhy it can sleep without holding a server #Durable orchestration does not keep a suspended .NET stack in memory. It records every durable call. When an activity finishes, a timer fires or an event arrives, the runtime calls the orchestrator method again from the top and feeds the recorded results back from history.\nsequenceDiagram participant O as Orchestrator participant H as Task hub history participant A as Activity O-\u003e\u003eH: Read saved inspection result H--\u003e\u003eO: Replay recorded result O-\u003e\u003eH: Read review event H--\u003e\u003eO: Replay delivered decision O-\u003e\u003eA: Route document as the first new step A--\u003e\u003eH: Record the outcome Replay explains both the durability and the constraint. Orchestrator code has to behave the same way on every pass, which rules out a few things people reach for by reflex.\nNot in the orchestrator Instead DateTime.UtcNow The context\u0026rsquo;s current UTC time Guid.NewGuid() A replay safe id from the context Direct HTTP, database or file calls An activity Task.Delay or Thread.Sleep A durable timer Reading mutable configuration inline An activity that records the value Activities, on the other hand, can still be called twice. A failure can leave an external operation uncertain, so they need idempotency keys or a unique constraint behind them. What you get in return is a checkpoint: a completed activity replays from its stored result rather than calling the outside world again. A crash between durable steps is therefore unremarkable. A crash in the middle of an activity is the ordinary distributed systems problem, and it needs the ordinary distributed systems answers.\nThe shape of the code #The routing flow below uses the case id as the instance id. It tries to categorise the document first. When the result is uncertain it notifies a reviewer and waits for whichever happens first: a decision, a cancellation, or the deadline.\nflowchart LR N[\"Needs review\"] --\u003e M[\"Notify reviewer\"] M --\u003e W{\"Task.WhenAny\"} T[\"Durable timer expires\"] --\u003e W R[\"Review event arrives\"] --\u003e W C[\"Cancellation event arrives\"] --\u003e W W --\u003e|\"timer wins\"| E[\"Expire case\"] W --\u003e|\"review wins\"| D[\"Route document\"] W --\u003e|\"cancel wins\"| X[\"Close case\"] The activity implementations are left out on purpose. They are where the database access, the notification and the routing belong.\nusing Microsoft.DurableTask; public sealed class DocumentReviewOrchestrator : TaskOrchestrator\u0026lt;DocumentInput, WorkflowOutcome\u0026gt; { public override async Task\u0026lt;WorkflowOutcome\u0026gt; RunAsync( TaskOrchestrationContext context, DocumentInput input) { var proposal = await context.CallActivityAsync\u0026lt;CategoryProposal\u0026gt;( nameof(InspectDocumentActivity), input); if (proposal.IsConfident) return await RouteAsync(context, input, proposal.Category); await context.CallActivityAsync( nameof(NotifyReviewerActivity), new ReviewRequest(context.InstanceId, input.Title, proposal.Candidates)); context.SetCustomStatus(\u0026#34;WaitingForReview\u0026#34;); using var cts = new CancellationTokenSource(); Task deadline = context.CreateTimer(TimeSpan.FromHours(24), cts.Token); Task\u0026lt;ReviewDecision\u0026gt; review = context.WaitForExternalEvent\u0026lt;ReviewDecision\u0026gt;( \u0026#34;review-submitted\u0026#34;, cts.Token); Task\u0026lt;CancelRequest\u0026gt; cancel = context.WaitForExternalEvent\u0026lt;CancelRequest\u0026gt;( \u0026#34;review-cancelled\u0026#34;, cts.Token); Task winner = await Task.WhenAny(deadline, review, cancel); cts.Cancel(); if (winner == deadline) return await EndAsync(context, input.Id, \u0026#34;Expired\u0026#34;); if (winner == cancel) return await EndAsync(context, input.Id, \u0026#34;Cancelled\u0026#34;); ReviewDecision decision = await review; if (!decision.Approved) return await EndAsync(context, input.Id, \u0026#34;Rejected\u0026#34;); return await RouteAsync(context, input, decision.Category); } private static async Task\u0026lt;WorkflowOutcome\u0026gt; RouteAsync( TaskOrchestrationContext context, DocumentInput input, string category) { await context.CallActivityAsync( nameof(RouteDocumentActivity), new RouteRequest(input.Id, category)); await context.CallActivityAsync( nameof(SetCaseStatusActivity), new StatusChange(input.Id, \u0026#34;Routed\u0026#34;)); return new WorkflowOutcome(\u0026#34;Routed\u0026#34;); } private static async Task\u0026lt;WorkflowOutcome\u0026gt; EndAsync( TaskOrchestrationContext context, string caseId, string status) { await context.CallActivityAsync( nameof(SetCaseStatusActivity), new StatusChange(caseId, status)); return new WorkflowOutcome(status); } } The cts.Cancel() line is easy to skip and worth keeping. Once one wait wins, the others are cancelled with it, including the timer. An outstanding durable timer can stop an otherwise finished instance from ever reaching a completed state.\nThe API stays ordinary #The reviewer endpoint has no workflow logic in it. It authorises the request, validates the payload, and signals the instance that is already waiting.\nusing Microsoft.DurableTask.Client; public static async Task\u0026lt;IResult\u0026gt; SubmitReview( string caseId, ReviewDecision decision, DurableTaskClient client) { await client.RaiseEventAsync(caseId, \u0026#34;review-submitted\u0026#34;, decision); return Results.Accepted(); } That division is useful operationally as well. The API scales for short request and response work, workers scale for orchestration and activities, and the task hub is the handoff between them.\nTwo kinds of state #The scheduler knows about pending, running, completed, failed and terminated. That is the runtime view, and it means very little to the person watching the queue. Support needs the business view: waiting for review, routed, rejected, expired, cancelled.\nWrite the business state deliberately, through an activity, and set a short custom status for operators. Then a dashboard can answer both questions without mixing them up: is the runtime still working on this, and what does this case mean for the business.\nWhat production adds #The orchestration above is short, but it does not remove the usual distributed systems work. It moves it to the boundaries where it belongs.\nUse a stable instance id. A case, order or request id makes events, status queries, support links and retry rules point at the same instance. Make activities idempotent. An idempotency key, a unique constraint or provider side deduplication, so a retry is safe. Assume events arrive at least once. Carry an event id, ignore duplicates, and check that the user is allowed to act on the case before raising anything. Keep the orchestrator deterministic, and read time, randomness and configuration through durable boundaries. Cancel the losing waits as soon as a winner is chosen. Version with in-flight instances in mind. They can replay old code paths, so either keep compatibility or drain them before a breaking change. Treat history as operational data. Inputs, outputs, custom statuses and event payloads are persisted, so do not put anything sensitive in them without deciding how it will be stored, accessed and expired. Retries belong on activities, usually around network calls, and they need a policy rather than a default: how many attempts, how long to wait, how to back off, and which state to move to when the budget is gone.\nWhere it fits #Document routing is one shape. The pattern applies wherever a process pauses and a clear action resumes it.\nSituation The wait The event AI draft needs an editor Approval or requested changes draft-reviewed Large order needs an exception A fulfilment decision fulfillment-selected Data export needs sign off Compliance approval export-approved Account recovery Identity verification verification-complete When not to reach for it #A durable workflow is for long-lived coordination, not for everything asynchronous. Sending an email, refreshing a cache or running one background calculation is fine as a queue message and a worker.\nIt earns its place when several of these are true at once: the wait is measured in minutes, hours or days; a person or another service resumes a specific operation; the deadline has to survive restarts; support needs to see the history; and the steps must coordinate safely through retries and failure.\nIf the work is short, independent and fire and forget, a job queue is the lighter answer.\nStart with one wait #None of this requires rewriting an application. The first version can be narrow.\nPick a workflow that already waits on a person. Give it a stable instance id and one orchestration method. Put existing I/O behind activities instead of replacing the services that do the work. Let the existing API raise an event rather than reissuing the original command. Add the durable timer for the deadline and surface the business status for support. Walk the happy path, a rejection, a cancellation, a timeout, a retry and a worker restart before going further. Local emulation and a workflow dashboard make that practical. You can open an instance\u0026rsquo;s history, stop a worker, start it again, and watch a pending review resume, which is a much better argument than a diagram.\nThe shift #The part worth internalising is small. A human answer is not a detour that starts a fresh job. It is another event in the life of the same process.\nOnce the workflow owns that life, the code reads like the rule it implements: inspect, wait if needed, take the first valid answer, carry on. History, replay, timers and recovery stay with the scheduler, and the application code stays about the decision.\nLinks: Durable Task SDK · Human interaction · Orchestrator constraints · Durable Task Scheduler\n","date":"17 September 2026","permalink":"https://anjula.dev/durable-human-workflows/","section":"Blog","summary":"Durable orchestration records a human wait and resumes it without a hand-rolled state machine.","title":"Durable workflows for human waits"},{"content":"Every project has a configuration key that only exists in production. Ours was a cache TTL. The code asked for Cache:TtlSeconds; the JSON file said Cache:TTLSeconds. GetValue\u0026lt;int\u0026gt; returned 0, nothing threw, and every cache entry expired the moment it was written. The database absorbed the traffic and it took an afternoon to trace the storm of queries back to one missing letter.\nThat leniency is deliberate. When a key is missing and no default is supplied, GetValue\u0026lt;T\u0026gt;() returns default(T), which is 0, false or null. GetConnectionString() is the same story and returns null. The failure then shows up wherever that value gets used, which is rarely near the configuration.\nThe framework already accepts the argument for strictness. .NET 6 added GetRequiredSection(), which throws when a section is absent, but there is still nothing for a single value. The proposal has been open for years. These are the ten lines that fill the gap.\nTwo extensions #using Microsoft.Extensions.Configuration; namespace MyApp.Configuration; public static class ConfigurationExtensions { public static T GetRequiredValue\u0026lt;T\u0026gt;(this IConfiguration configuration, string key) { ArgumentNullException.ThrowIfNull(configuration); ArgumentException.ThrowIfNullOrWhiteSpace(key); if (!configuration.GetSection(key).Exists()) { throw new InvalidOperationException( $\u0026#34;Required configuration value \u0026#39;{key}\u0026#39; was not found.\u0026#34;); } var value = configuration.GetValue\u0026lt;T\u0026gt;(key); if (value is null || value is string s \u0026amp;\u0026amp; string.IsNullOrWhiteSpace(s)) { throw new InvalidOperationException( $\u0026#34;Required configuration value \u0026#39;{key}\u0026#39; is empty.\u0026#34;); } return value; } public static string GetRequiredConnectionString(this IConfiguration configuration, string name) { ArgumentNullException.ThrowIfNull(configuration); ArgumentException.ThrowIfNullOrWhiteSpace(name); var connectionString = configuration.GetConnectionString(name); if (string.IsNullOrWhiteSpace(connectionString)) { throw new InvalidOperationException( $\u0026#34;Required connection string \u0026#39;{name}\u0026#39; was not found. \u0026#34; + $\u0026#34;Add it under \u0026#39;ConnectionStrings:{name}\u0026#39;.\u0026#34;); } return connectionString; } } The checks are doing three separate jobs.\nChecking Exists() before binding is what catches a missing int. Without it, GetValue\u0026lt;int\u0026gt; is happy to convert nothing into 0, which is the bug we are trying to kill. An empty string is treated as a failure too, because \u0026quot;ApiKey\u0026quot;: \u0026quot;\u0026quot; is a misconfiguration rather than a value. And the exception names the key, which is what turns a midnight mystery into a one line fix. Values that exist but cannot be parsed still throw from the binder, which is also fine.\nThe whole method is one decision chain with no quiet exits:\nflowchart TD A[\"GetRequiredValue(key)\"] --\u003e B{\"Section exists?\"} B --\u003e|\"No\"| C[\"Throw: key not found\"] B --\u003e|\"Yes\"| D[\"Bind to T\"] D --\u003e|\"Fails\"| E[\"Binder throws\"] D --\u003e|\"OK\"| F{\"Null or blank?\"} F --\u003e|\"Yes\"| G[\"Throw: value is empty\"] F --\u003e|\"No\"| H[\"Return value\"] Using them #Read required settings while the application is being built, so a bad deployment dies immediately with a message instead of misbehaving for hours.\nvar ttl = builder.Configuration.GetRequiredValue\u0026lt;int\u0026gt;(\u0026#34;Cache:TtlSeconds\u0026#34;); var db = builder.Configuration.GetRequiredConnectionString(\u0026#34;OrdersDb\u0026#34;); On terminology, since it comes up: this is not ASP.NET Core specific. ASP.NET Core and plain .NET hosts share the same Microsoft.Extensions.Configuration stack, so the extensions work in a console app, a worker service or a test host.\nTesting without mocks #Extension methods are static, so Moq and NSubstitute cannot intercept them, and trying to mock IConfiguration would only verify the mock. Run the real binder over a real, in-memory configuration instead. ConfigurationBuilder.AddInMemoryCollection() gives you an IConfigurationRoot backed by a dictionary, and it behaves like production for nested keys, type conversion and ConnectionStrings lookup.\nusing Microsoft.Extensions.Configuration; using MyApp.Configuration; public sealed class ConfigurationExtensionsTests { private static IConfiguration BuildConfig(IDictionary\u0026lt;string, string?\u0026gt; values) =\u0026gt; new ConfigurationBuilder().AddInMemoryCollection(values).Build(); [Fact] public void GetRequiredValue_Returns_Configured_Values() { var config = BuildConfig(new Dictionary\u0026lt;string, string?\u0026gt; { [\u0026#34;Api:BaseUrl\u0026#34;] = \u0026#34;https://api.example.com\u0026#34;, [\u0026#34;Cache:TtlSeconds\u0026#34;] = \u0026#34;60\u0026#34;, [\u0026#34;Cache:Enabled\u0026#34;] = \u0026#34;true\u0026#34;, }); Assert.Equal(\u0026#34;https://api.example.com\u0026#34;, config.GetRequiredValue\u0026lt;string\u0026gt;(\u0026#34;Api:BaseUrl\u0026#34;)); Assert.Equal(60, config.GetRequiredValue\u0026lt;int\u0026gt;(\u0026#34;Cache:TtlSeconds\u0026#34;)); Assert.True(config.GetRequiredValue\u0026lt;bool\u0026gt;(\u0026#34;Cache:Enabled\u0026#34;)); } [Fact] public void GetRequiredValue_Throws_With_Key_Name_When_Missing() { var config = BuildConfig(new Dictionary\u0026lt;string, string?\u0026gt;()); var ex = Assert.Throws\u0026lt;InvalidOperationException\u0026gt;( () =\u0026gt; config.GetRequiredValue\u0026lt;int\u0026gt;(\u0026#34;Cache:TtlSeconds\u0026#34;)); Assert.Contains(\u0026#34;Cache:TtlSeconds\u0026#34;, ex.Message); } [Fact] public void GetRequiredValue_Throws_When_Value_Is_Empty() { var config = BuildConfig(new Dictionary\u0026lt;string, string?\u0026gt; { [\u0026#34;Api:ApiKey\u0026#34;] = \u0026#34;\u0026#34;, }); Assert.Throws\u0026lt;InvalidOperationException\u0026gt;( () =\u0026gt; config.GetRequiredValue\u0026lt;string\u0026gt;(\u0026#34;Api:ApiKey\u0026#34;)); } [Fact] public void GetRequiredConnectionString_Returns_Configured_Value() { var config = BuildConfig(new Dictionary\u0026lt;string, string?\u0026gt; { [\u0026#34;ConnectionStrings:OrdersDb\u0026#34;] = \u0026#34;Server=db;Database=orders;\u0026#34;, }); Assert.Equal( \u0026#34;Server=db;Database=orders;\u0026#34;, config.GetRequiredConnectionString(\u0026#34;OrdersDb\u0026#34;)); } [Fact] public void GetRequiredConnectionString_Throws_When_Missing_Or_Empty() { var missing = BuildConfig(new Dictionary\u0026lt;string, string?\u0026gt;()); var empty = BuildConfig(new Dictionary\u0026lt;string, string?\u0026gt; { [\u0026#34;ConnectionStrings:OrdersDb\u0026#34;] = \u0026#34; \u0026#34;, }); Assert.Throws\u0026lt;InvalidOperationException\u0026gt;( () =\u0026gt; missing.GetRequiredConnectionString(\u0026#34;OrdersDb\u0026#34;)); Assert.Throws\u0026lt;InvalidOperationException\u0026gt;( () =\u0026gt; empty.GetRequiredConnectionString(\u0026#34;OrdersDb\u0026#34;)); } } The test project needs Microsoft.Extensions.Configuration, Microsoft.Extensions.Configuration.Binder, xunit, xunit.runner.visualstudio and Microsoft.NET.Test.Sdk. One detail worth copying: the connection string test writes the key as ConnectionStrings:OrdersDb, which is exactly how GetConnectionString() resolves it, so the fake exercises the real lookup path rather than a convenient shortcut.\nWhat it changes #Not much code, but the failure moves. A missing setting now stops the process at startup with the key name in the message, instead of turning into a strange number somewhere deeper in the system. Required configuration is validated the same way required services are, and when the runtime eventually ships GetRequiredValue itself, this file can be deleted.\nLinks: dotnet/runtime proposal for GetRequiredValue\n","date":"15 July 2026","permalink":"https://anjula.dev/fail-fast-configuration/","section":"Blog","summary":"Make a missing configuration key throw at startup instead of returning a silent default.","title":"Fail fast on missing configuration"},{"content":"Hello there! #I\u0026rsquo;m Anjula Karunarathne, a Microsoft Certified Azure AI Developer Associate working as an Associate Tech Lead from Colombo, Sri Lanka. With 4+ years building SaaS products across React, Vue.js, TypeScript, ASP.NET Core and SQL Server, I focus on AI adoption and AI-driven workflows on Azure cloud native setups, alongside delivery, releases and mentoring.\n","date":null,"permalink":"https://anjula.dev/","section":"","summary":"\u003ch1 id=\"hello-there\" class=\"relative group\"\u003eHello there! \u003cspan class=\"absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100\"\u003e\u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700\" style=\"text-decoration-line: none !important;\" href=\"#hello-there\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\u003c/span\u003e\u003c/h1\u003e\u003cp\u003eI\u0026rsquo;m \u003cstrong\u003eAnjula Karunarathne\u003c/strong\u003e, a Microsoft Certified Azure AI Developer Associate working as an Associate Tech Lead from Colombo, Sri Lanka. With 4+ years building SaaS products across React, Vue.js, TypeScript, ASP.NET Core and SQL Server, I focus on AI adoption and AI-driven workflows on Azure cloud native setups, alongside delivery, releases and mentoring.\u003c/p\u003e","title":""},{"content":"","date":null,"permalink":"https://anjula.dev/blog/azure/","section":"Tags","summary":"","title":"azure"},{"content":"","date":null,"permalink":"https://anjula.dev/blog/","section":"Blog","summary":"","title":"Blog"},{"content":"","date":null,"permalink":"https://anjula.dev/blog/csharp/","section":"Tags","summary":"","title":"csharp"},{"content":"","date":null,"permalink":"https://anjula.dev/blog/distributed-systems/","section":"Tags","summary":"","title":"distributed-systems"},{"content":"","date":null,"permalink":"https://anjula.dev/blog/dotnet/","section":"Tags","summary":"","title":"dotnet"},{"content":"fxtrack #Live web app · 2026\nSri Lankan banks all publish a US dollar rate, and none of them agree. Buying from the wrong one on the wrong morning is worth a real detour, which is why the answer to \u0026ldquo;who is cheapest today\u0026rdquo; normally takes ten browser tabs. fxtrack reads those published pages four times a day and puts them in one place.\nYou pick what you are doing, buying or selling, by wire, in cash or with a card, and it ranks the banks for that case, names the best one right now, and shows the savings on the amount you entered. Each rate is also set against the Central Bank\u0026rsquo;s indicative mid, so you can see whether today is a reasonable moment or one to wait out.\nThe pipeline is the part I care about. A scheduled job reads every bank\u0026rsquo;s page, merges what it finds into append only CSVs, and rebuilds one snapshot per chart window. A field the bank stops publishing never erases the last value we hold, and a bank that goes quiet for a day drops off the board instead of quietly showing a stale number. The site itself is a Vue and TypeScript static build served from GitHub Pages on a subdomain, so there is no server in the request path.\nLinks: fxtrack.anjula.dev · Source\nForecasting Dogecoin prices with Twitter sentiment and deep learning #Final-year research · University of Colombo · 2022\nFor my final year project I looked at whether the mood on Twitter carried any signal about the price of Dogecoin, a coin that spent 2021 being moved mostly by mood. I collected hourly prices from CoinGecko and three million tweets, filtered the tweets down to English, stripped retweets and bot like accounts, and scored sentiment with VADER.\nThe models had to predict six hourly prices ahead and were graded with walk-forward validation, which avoids the easy mistake of testing on data the model has already seen. Three architectures, trained on price history and sentiment together:\nModel MAPE RMSE MAE Vanilla LSTM 8.74% 0.080 0.050 Encoder-decoder LSTM 12.71% 0.106 0.071 CNN-LSTM encoder-decoder 16.26% 0.165 0.106 The plain LSTM did best, which was the useful result: the extra machinery of the seq2seq variants did not pay for itself at this horizon, and the sentiment signal was worth keeping but not strong enough to carry the prediction on its own.\nLinks: Code and data pipeline · Paper, watermarked preview\nCustom keyboard shortcuts for Joplin #Google Summer of Code · 2020\nJoplin\u0026rsquo;s desktop app had hardcoded keyboard shortcuts and no way to change them. Over a summer I built a shortcut system for it, in two layers.\nThe first was a keymap service that keeps an in-memory map from commands to shortcuts, seeded from platform specific defaults and overridable from a keymap file in the profile directory, which takes priority. It exposes methods for reading and changing bindings and validates continuously, so the map cannot drift into a conflicted state.\nThe second was the editor, which lists every command with its shortcut and supports changing, disabling, restoring to the default, exporting and importing the keymap as JSON, and searching. Edits land in the profile keymap file straight away. Both parts shipped with the specification, interface sketches and weekly reports that went with the project.\nLinks: GSoC project · KeymapService · Editor · Work product\n","date":null,"permalink":"https://anjula.dev/projects/","section":"","summary":"\u003ch2 id=\"fxtrack\" class=\"relative group\"\u003efxtrack \u003cspan class=\"absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100\"\u003e\u003ca class=\"group-hover:text-primary-300 dark:group-hover:text-neutral-700\" style=\"text-decoration-line: none !important;\" href=\"#fxtrack\" aria-label=\"Anchor\"\u003e#\u003c/a\u003e\u003c/span\u003e\u003c/h2\u003e\u003cp\u003e\u003cem\u003eLive web app · 2026\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eSri Lankan banks all publish a US dollar rate, and none of them agree. Buying from the wrong one on the wrong morning is worth a real detour, which is why the answer to \u0026ldquo;who is cheapest today\u0026rdquo; normally takes ten browser tabs. fxtrack reads those published pages four times a day and puts them in one place.\u003c/p\u003e","title":"Projects"},{"content":"","date":null,"permalink":"https://anjula.dev/blog/testing/","section":"Tags","summary":"","title":"testing"},{"content":"","date":null,"permalink":"https://anjula.dev/blog/workflow-automation/","section":"Tags","summary":"","title":"workflow-automation"}]