Move tracker policy rendering out of write transaction

PublishTrackerPolicy ran template execution, markdown parsing and JSON
marshaling inside the WithTx callback, holding a write transaction open
across CPU-bound work. Split it into a read phase (WithConn) that
gathers data and renders the document, followed by a write phase that
persists the document and version, mirroring PublishThirdPartyList.

Also sort the generated third-party rows before returning. LoadByIDs
has no ORDER BY, so the policy document could otherwise be emitted in a
different row order on each regeneration.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-02 10:08:05 +02:00
parent 73854f98cb
commit 6eff9ecb97

View File

@@ -20,6 +20,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"slices"
"strings" "strings"
"text/template" "text/template"
"time" "time"
@@ -73,29 +74,56 @@ func (s *GeneratedDocumentService) PublishTrackerPolicy(
scope coredata.Scoper, scope coredata.Scoper,
cookieBannerID gid.GID, cookieBannerID gid.GID,
) error { ) error {
return s.svc.pg.WithTx( // Phase 1: collect data and render the prosemirror document outside any
ctx, // write transaction. Template rendering, markdown parsing, and JSON
func(ctx context.Context, tx pg.Tx) error { // marshaling are CPU work that should not hold a write transaction open.
var (
organizationID gid.GID
bannerOrigin string
documentData docgen.TrackerPolicyData
)
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
banner := &coredata.CookieBanner{} banner := &coredata.CookieBanner{}
if err := banner.LoadByID(ctx, tx, scope, cookieBannerID); err != nil { if err := banner.LoadByID(ctx, conn, scope, cookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err) return fmt.Errorf("cannot load cookie banner: %w", err)
} }
organization := &coredata.Organization{} organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, banner.OrganizationID); err != nil { if err := organization.LoadByID(ctx, conn, scope, banner.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err) return fmt.Errorf("cannot load organization: %w", err)
} }
documentData, err := s.buildTrackerPolicyDocumentData(ctx, scope, tx, organization, banner) var err error
documentData, err = s.buildTrackerPolicyDocumentData(ctx, scope, conn, organization, banner)
if err != nil { if err != nil {
return fmt.Errorf("cannot build document data: %w", err) return fmt.Errorf("cannot build document data: %w", err)
} }
organizationID = banner.OrganizationID
bannerOrigin = banner.Origin
return nil
})
if err != nil {
return err
}
prosemirrorJSON, err := BuildTrackerPolicyDocument(documentData) prosemirrorJSON, err := BuildTrackerPolicyDocument(documentData)
if err != nil { if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err) return fmt.Errorf("cannot build prosemirror document: %w", err)
} }
// Phase 2: persist the document and version in a write transaction.
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
banner := &coredata.CookieBanner{}
if err := banner.LoadByID(ctx, tx, scope, cookieBannerID); err != nil {
return fmt.Errorf("cannot reload cookie banner: %w", err)
}
now := time.Now() now := time.Now()
var ( var (
@@ -128,7 +156,7 @@ func (s *GeneratedDocumentService) PublishTrackerPolicy(
document = &coredata.Document{ document = &coredata.Document{
ID: documentID, ID: documentID,
OrganizationID: banner.OrganizationID, OrganizationID: organizationID,
WriteMode: coredata.DocumentWriteModeGenerated, WriteMode: coredata.DocumentWriteModeGenerated,
TrustCenterVisibility: coredata.TrustCenterVisibilityPrivate, TrustCenterVisibility: coredata.TrustCenterVisibilityPrivate,
Status: coredata.DocumentStatusActive, Status: coredata.DocumentStatusActive,
@@ -153,9 +181,9 @@ func (s *GeneratedDocumentService) PublishTrackerPolicy(
documentVersionID := gid.New(scope.GetTenantID(), coredata.DocumentVersionEntityType) documentVersionID := gid.New(scope.GetTenantID(), coredata.DocumentVersionEntityType)
documentVersion := &coredata.DocumentVersion{ documentVersion := &coredata.DocumentVersion{
ID: documentVersionID, ID: documentVersionID,
OrganizationID: banner.OrganizationID, OrganizationID: organizationID,
DocumentID: document.ID, DocumentID: document.ID,
Title: fmt.Sprintf("Cookie and Tracking Technologies Policy — %s", banner.Origin), Title: fmt.Sprintf("Cookie and Tracking Technologies Policy — %s", bannerOrigin),
Content: prosemirrorJSON, Content: prosemirrorJSON,
Classification: coredata.DocumentClassificationPublic, Classification: coredata.DocumentClassificationPublic,
DocumentType: coredata.DocumentTypePolicy, DocumentType: coredata.DocumentTypePolicy,
@@ -164,7 +192,7 @@ func (s *GeneratedDocumentService) PublishTrackerPolicy(
UpdatedAt: now, UpdatedAt: now,
} }
return s.publishOrRequestApproval(ctx, scope, tx, document, documentVersion, banner.OrganizationID, nil, false, now) return s.publishOrRequestApproval(ctx, scope, tx, document, documentVersion, organizationID, nil, false, now)
}, },
) )
} }
@@ -262,6 +290,21 @@ func (s *GeneratedDocumentService) buildTrackerPolicyThirdParties(
rows = append(rows, row) rows = append(rows, row)
} }
// LoadByIDs returns rows in an unspecified order (no ORDER BY), so sort
// here to keep the generated policy document deterministic across
// regenerations.
slices.SortFunc(rows, func(a, b docgen.TrackerPolicyThirdParty) int {
if c := strings.Compare(a.Name, b.Name); c != 0 {
return c
}
if c := strings.Compare(a.Description, b.Description); c != 0 {
return c
}
return strings.Compare(a.PrivacyPolicyURL, b.PrivacyPolicyURL)
})
return rows, nil return rows, nil
} }