The import of the tenders is the longest path in the Kladde. A scheduled job fetches the notices from TED at night, stores each one raw in
Blob Storage and sends a reference to it through Service Bus. A worker reads the message, fetches the notice from the blob, writes it to
SQL and announces it on the topic tender-events. Next to it runs the CSV import of the contacts through Event Grid. Every part comes
from modules 3.5 and 3.6. In this lesson the whole thing runs in the new subscription, and you prove with a drill that it counts
correctly even when messages arrive twice or are broken.
job-kladde-import (02:30 UTC)
│ query TED (timeout, retry for GET only, 3.9)
├─► tenders-raw/<date>/<notice>.json Blob Storage, raw (3.6.3)
└─► tender-import { "blob": "..." } Service Bus, only the reference (claim check, 3.5.1)
│ MessageId = <notice>:<version>, duplicate detection within the time window
▼
Worker (KEDA, down to zero, 3.5.7)
├─ unreadable ────────► dead letter with a reason (3.5.5)
├─ already known ─────► complete, nothing more (3.5.4)
└─ new ───────────────► SQL (unique key), tender-events, metrics
imports/*.csv ──► Event Grid ──► contact-import ──► Worker ──► contacts (3.5.6)
What exists only at creation
Some settings can only be set when a resource is created. If you want them later, you have to delete the resource and create it again,
with everything in it. For the import, the most important of these is the duplicate detection of Service Bus: you can switch it on
only when you create a queue, and only in the Standard or Premium tier (3.5.4). If tender-import had been created without it in the old
subscription, a Bicep file that asks for it afterward would only have produced an error. In the empty subscription the queue is created
fresh, and this time you set it correctly.
The time window of the duplicate detection, in which Service Bus remembers a MessageId, can be changed later, though, anywhere between
20 seconds and 7 days. The Kladde uses ten minutes in prod. In dev and test it uses one minute; the drill below shows why.
// Excerpt from infra/main.bicep: the containers, queues and the Event Grid subscription of the imports, created fresh in
// the empty subscription. Some settings exist only at creation; this is the moment to get them right.
@allowed(['dev', 'test', 'prod'])
param environmentName string
param location string = resourceGroup().location
param serviceBusName string
param storageAccountName string
var serviceBusDataSender = '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39'
resource serviceBus 'Microsoft.ServiceBus/namespaces@2024-01-01' existing = {
name: serviceBusName
}
resource storage 'Microsoft.Storage/storageAccounts@2025-06-01' existing = {
name: storageAccountName
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2025-06-01' existing = {
parent: storage
name: 'default'
}
resource rawContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = {
parent: blobService
name: 'tenders-raw'
}
resource importsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2025-06-01' = {
parent: blobService
name: 'imports'
}
// Duplicate detection can only be switched on when a queue is created. In dev and test the window is one minute,
// so the import drill can send a late duplicate without waiting ten minutes.
resource tenderImport 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = {
parent: serviceBus
name: 'tender-import'
properties: {
requiresDuplicateDetection: true
duplicateDetectionHistoryTimeWindow: environmentName == 'prod' ? 'PT10M' : 'PT1M'
maxDeliveryCount: 5
lockDuration: 'PT1M'
deadLetteringOnMessageExpiration: true
}
}
resource contactImport 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = {
parent: serviceBus
name: 'contact-import'
properties: {
maxDeliveryCount: 5
lockDuration: 'PT1M'
deadLetteringOnMessageExpiration: true
}
}
// A new CSV in the container imports becomes a message in contact-import (lesson 3.5.6). Event Grid delivers with its
// own identity; the storage account has no keys any more, and Service Bus none either.
resource blobEvents 'Microsoft.EventGrid/systemTopics@2025-02-15' = {
name: 'evgt-kladde-${environmentName}-storage'
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
source: storage.id
topicType: 'Microsoft.Storage.StorageAccounts'
}
}
resource eventsMaySend 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(contactImport.id, blobEvents.id, serviceBusDataSender)
scope: contactImport
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', serviceBusDataSender)
principalId: blobEvents.identity.principalId
principalType: 'ServicePrincipal'
}
}
resource csvToQueue 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2025-02-15' = {
parent: blobEvents
name: 'contact-csv-to-queue'
properties: {
eventDeliverySchema: 'CloudEventSchemaV1_0'
deliveryWithResourceIdentity: {
identity: {
type: 'SystemAssigned'
}
destination: {
endpointType: 'ServiceBusQueue'
properties: {
resourceId: contactImport.id
}
}
}
filter: {
includedEventTypes: ['Microsoft.Storage.BlobCreated']
subjectBeginsWith: '/blobServices/default/containers/${importsContainer.name}/blobs/'
subjectEndsWith: '.csv'
}
retryPolicy: {
maxDeliveryAttempts: 10
eventTimeToLiveInMinutes: 1440
}
}
dependsOn: [eventsMaySend] // the role on the destination first, then the subscription that uses it
}
output rawContainerName string = rawContainer.name
What is in this file and why:
- The containers
tenders-rawandimportsare created with the storage account from 3.10.4. They appear here because the import needs them. maxDeliveryCount: 5: a message that has not been completed five times lands in the dead letter queue by itself. The poison message of the drill, though, is not supposed to be tried five times at all; the worker puts it there at once, with a reason.- Event Grid delivers with an identity of its own (
deliveryWithResourceIdentity). The system topic gets a system-assigned identity, and that identity gets Azure Service Bus Data Sender on exactly that one queue. This is one of the roles the deploy identity may assign according to 3.10.2, and the recipient is a service principal; so the condition lets the assignment through. dependsOn: [eventsMaySend]: Microsoft describes the order like this: first the role on the destination, then the subscription that delivers with the identity. Without the role, Event Grid cannot write to the queue. The subscription nowhere refers to the role assignment, so Bicep cannot see this dependency by itself and needs thedependsOn.- The filter takes only
BlobCreatedfor files ending in.csvin the containerimports. WithoutsubjectBeginsWith, every new file intenders-rawwould also create a message incontact-import, hundreds every night. CloudEventSchemaV1_0is the format the worker has been reading since 3.5.6.
The topic tender-events and its subscriptions from 3.5.3 you carry over into the same file as you built them.