identity

package
v0.0.0-...-9a2a36c Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 8, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

README

Identity Ingest Path

This package implements the single bulk-upsert path that all writers use to insert email→login resolutions into the email_resolution table, ensuring consistent conflict resolution per plan.md.

Overview

The ingest path applies a deterministic conflict resolution rule when inserting or updating email resolutions:

  • Manual source always wins - A manually curated resolution is never overwritten
  • Otherwise, newer wins - The resolution with the newer resolved_at timestamp wins
  • Provenance is auditable - Every row carries its source (live/seed/manual)

Writers

All three writers go through this single ingest path:

  1. Live enrichment worker (source='live') - Resolves emails via GitHub API during live operation
  2. Claude-leaderboard seed (source='seed') - Bulk import of 349,425 frozen pairs from claude-leaderboard
  3. Manual curation (source='manual') - Operator-authored aliases

Conflict Resolution Rule

The PostgreSQL ON CONFLICT clause implements the rule exactly as specified in plan.md:

ON CONFLICT (email) DO UPDATE
  SET login = excluded.login, source = excluded.source, resolved_at = excluded.resolved_at
  WHERE excluded.source = 'manual'
     OR (email_resolution.source <> 'manual'
         AND excluded.resolved_at > email_resolution.resolved_at)
Semantics
Incoming Source Existing Source Result
manual any Incoming wins (manual always overwrites)
any manual Existing wins (manual is never overwritten)
live/seed live/seed, newer Existing wins (older incoming is skipped)
live/seed live/seed, older Incoming wins (newer resolution)

Rows that lose the conflict check are silently skipped (upsert semantics, not batch failure).

Usage

Basic Example
import (
    "context"
    "time"
    
    "github.com/jedarden/commitgraph/pkg/identity"
    "github.com/jedarden/commitgraph/pkg/pg"
)

func main() {
    // Initialize database connection (example with database/sql)
    db, _ := sql.Open("postgres", "...")
    
    // Create PostgreSQL ingester
    ingester := pg.NewIdentityIngester(db)
    
    // Create identity ingester
    identityIngester := identity.NewIngester(ingester)
    
    // Prepare batch of resolutions
    rows := []identity.ResolutionRow{
        {
            Email:      "user@example.com",
            Login:      "userlogin",
            Source:     identity.SourceLive,
            ResolvedAt: time.Now().UTC(),
        },
        // ... more rows
    }
    
    // Ingest batch
    err := identityIngester.IngestResolution(context.Background(), rows)
    if err != nil {
        // Handle error
    }
}
With Transaction
func ingestWithTx(db *sql.DB, rows []identity.ResolutionRow) error {
    tx, _ := db.Begin()
    defer tx.Rollback()
    
    ingester := pg.NewIdentityIngester(tx)
    identityIngester := identity.NewIngester(ingester)
    
    if err := identityIngester.IngestResolution(context.Background(), rows); err != nil {
        return err
    }
    
    return tx.Commit()
}
Large Batch (349K+ rows)

The implementation uses PostgreSQL's UNNEST with array parameters for efficient bulk insert:

// This handles 349,425 rows from claude-leaderboard seed efficiently
// Single round-trip, no per-row overhead
rows := loadClaudeLeaderboardSeed() // 349,425 rows
err := identityIngester.IngestResolution(ctx, rows)

Schema

The email_resolution table (from migrations/001_initial_schema.sql):

CREATE TABLE email_resolution (
  email       TEXT PRIMARY KEY,
  login       TEXT NOT NULL,
  source      TEXT NOT NULL,          -- 'live' | 'seed' | 'manual'
  resolved_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON email_resolution (login);

Validation

The ingest path validates all rows before database insertion:

  • email cannot be empty
  • login cannot be empty
  • source must be one of: live, seed, manual
  • resolved_at cannot be zero

Validation failures return an error with the row index: "row 42: email cannot be empty".

Design Rationale

Why a single path?

The plan specifies this as the single path all writers use because:

  1. Consistent conflict resolution - No writer can bypass the rule
  2. Auditable provenance - Every row's source is recorded
  3. Idempotent operation - Safe to retry (e.g., during migration)
Why bulk operation?

The claude-leaderboard seed alone is 349,425 rows. Row-at-a-time inserts would require 349,425 round trips. The bulk implementation uses a single SQL statement with UNNEST array parameters.

Why UNNEST instead of VALUES?

PostgreSQL's UNNEST with typed array parameters (::text[], ::timestamptz[]) is:

  • Efficient - Single query execution plan
  • Type-safe - Parameter typing prevents injection
  • Scalable - Handles thousands of rows per batch

Implementation Details

PostgreSQL Implementation

pkg/pg/identity.go implements the identity.DB interface for PostgreSQL using database/sql:

type DB interface {
    IngestEmailResolution(ctx context.Context, rows []identity.ResolutionRow) error
}

The concrete implementation uses UNNEST to expand array parameters into rows:

INSERT INTO email_resolution (email, login, source, resolved_at)
SELECT unnest($1::text[]),
       unnest($2::text[]),
       unnest($3::text[]),
       unnest($4::timestamptz[])
Mock Testing

Both packages include comprehensive tests using mock implementations:

  • pkg/identity/ingest_test.go - Tests validation logic
  • pkg/pg/identity_test.go - Tests SQL generation and conflict rule

Run tests:

go test ./pkg/identity/... -v
go test ./pkg/pg/... -v

References

Documentation

Overview

Package identity provides bulk identity resolution ingest functionality.

The ingest path is the single way all writers (live enrichment worker, claude-leaderboard seed, manual curation) write email→login resolutions to the email_resolution table, ensuring consistent conflict resolution.

Package identity provides snapshot capture utilities for email resolution tables.

Example

Example: With real PostgreSQL connection This shows how to run the actual test with a live database

package main

import (
	"fmt"
)

func main() {
	// This would require a real PostgreSQL connection:
	// dbHost := os.Getenv("PGHOST")
	// dbUser := os.Getenv("PGUSER")
	// etc.

	// The test database /tmp/test_seed.db contains:
	// - 11 total rows
	// - 3 rows with empty logins
	// - 8 valid rows
	// - Some duplicate pairs for conflict testing

	// Expected results when seeded:
	// 1. 3 rows skipped (empty logins)
	// 2. 8 rows submitted to ingest
	// 3. Conflict resolution applied for duplicates
	// 4. Final table has unique email entries with winning values

	fmt.Println("See test database at /tmp/test_seed.db")
	fmt.Println("Run: sqlite3 /tmp/test_seed.db 'SELECT * FROM author_login_cache'")
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func CompareSnapshots

func CompareSnapshots(a, b *EmailResolutionSnapshot) (bool, error)

CompareSnapshots compares two snapshots and returns whether they are byte-for-byte identical.

Returns:

  • (true, nil) if snapshots are identical (same row count and hash)
  • (false, error) if snapshots differ, with detailed error message
  • (false, error) if either snapshot is nil

Types

type CaptureSnapshotOption

type CaptureSnapshotOption func(*captureSnapshotOptions)

CaptureSnapshotOption is a functional option for CaptureSnapshot.

func WithFullRowData

func WithFullRowData() CaptureSnapshotOption

WithFullRowData enables capturing the complete row data in the snapshot. This is useful for debugging and detailed analysis but uses more memory.

type DB

type DB interface {
	// IngestEmailResolution performs a bulk upsert of resolution rows.
	// The batch must apply the ON CONFLICT rule:
	//   - Manual source always wins
	//   - Otherwise, the newer resolved_at wins
	// Rows that lose the conflict check are silently skipped.
	// Returns IngestResult with counts of ingested and skipped rows.
	IngestEmailResolution(ctx context.Context, rows []ResolutionRow) (*IngestResult, error)
}

DB is the database interface required by Ingester. This allows for testing with mocks and supports different database drivers.

type EmailResolutionSnapshot

type EmailResolutionSnapshot struct {
	// RowCount is the total number of rows in the email_resolution table
	RowCount int

	// Hash is the SHA-256 checksum of all row data (sorted by email)
	// The hash includes all columns: email, login, source, resolved_at
	Hash string

	// Rows contains the full row data for debugging and detailed analysis.
	// This field is optional and can be nil for snapshots that only need
	// hash-based comparison.
	Rows []ResolutionRow
}

EmailResolutionSnapshot captures the complete state of the email_resolution table. This includes the row count, a cryptographic hash of all data, and optionally the full row data for debugging and detailed comparison.

func CaptureSnapshot

func CaptureSnapshot(db *sql.DB, opts ...CaptureSnapshotOption) (*EmailResolutionSnapshot, error)

CaptureSnapshot reads all rows from the email_resolution table and returns a snapshot containing the row count, cryptographic hash, and optionally the full row data.

The hash is computed by: 1. Reading all rows sorted by email (ensures consistent ordering) 2. Concatenating row data in a stable format 3. Computing SHA-256 of the concatenated data

This ensures the hash is sensitive to changes in any column (email, login, source, resolved_at) regardless of database storage order.

Parameters:

  • db: Database connection (can be PostgreSQL or SQLite)
  • opts: Optional configuration (e.g., WithFullRowData())

Returns an error if:

  • The database query fails
  • A row cannot be scanned
  • The timestamp format is invalid

type IngestResult

type IngestResult struct {
	// Ingested is the number of rows that were written (inserted or updated).
	Ingested int64
	// Skipped is the number of rows that were not written due to conflict resolution.
	Skipped int64
	// SkipDetails provides a breakdown of skip reasons.
	SkipDetails map[SkipReason]int64
}

IngestResult describes the outcome of a bulk ingest operation.

type Ingester

type Ingester struct {
	Processed   int64                // Total number of records processed (seen)
	Ingested    int64                // Total number of records successfully written (inserted or updated)
	Skipped     int64                // Total number of records skipped due to conflict resolution
	SkipDetails map[SkipReason]int64 // Breakdown of skip reasons
	// contains filtered or unexported fields
}

Ingester handles bulk upsert of email resolution rows with counter tracking.

func NewIngester

func NewIngester(db DB) *Ingester

NewIngester creates a new Ingester.

func (*Ingester) GetIngested

func (i *Ingester) GetIngested() int64

GetIngested returns the total number of records successfully ingested.

func (*Ingester) GetProcessed

func (i *Ingester) GetProcessed() int64

GetProcessed returns the total number of records processed.

func (*Ingester) GetSkipDetails

func (i *Ingester) GetSkipDetails() map[SkipReason]int64

GetSkipDetails returns the breakdown of skip reasons.

func (*Ingester) GetSkipped

func (i *Ingester) GetSkipped() int64

GetSkipped returns the total number of records skipped.

func (*Ingester) GetSummary

func (i *Ingester) GetSummary() map[string]interface{}

GetSummary returns a machine-readable, JSON-marshalable snapshot of the ingester's counters, suitable for logging at the end of an ingest run. Keys: "processed", "ingested", "skipped" (int64), "skip_details" (map[string]int64 keyed by skip reason string).

func (*Ingester) IngestResolution

func (i *Ingester) IngestResolution(ctx context.Context, rows []ResolutionRow) error

IngestResolution performs a bulk upsert of email resolution rows. It validates all rows first, then delegates to the database implementation.

The batch must use the ON CONFLICT rule from the plan:

ON CONFLICT (email) DO UPDATE
  SET login = excluded.login, source = excluded.source,
      resolved_at = excluded.resolved_at
  WHERE excluded.source = 'manual'
     OR (email_resolution.source <> 'manual'
         AND excluded.resolved_at > email_resolution.resolved_at)

This means:

  • A manual source always wins (overwrites any existing row)
  • A non-manual source wins only if the existing row is also non-manual AND has an older resolved_at timestamp
  • Otherwise the existing row is left unchanged

Rows that lose the conflict check are silently skipped - this is upsert semantics, not an all-or-nothing batch failure.

Returns an error if validation fails or the database operation fails. A partial failure (some rows succeed, some fail) returns an error.

type ResolutionRow

type ResolutionRow struct {
	Email      string    // Email address (primary key)
	Login      string    // Resolved GitHub login
	Source     Source    // Source of this resolution: live, seed, or manual
	ResolvedAt time.Time // When this resolution was made
}

ResolutionRow represents a single email→login resolution row.

func (*ResolutionRow) Validate

func (r *ResolutionRow) Validate() error

Validate checks if the row has valid fields.

type SkipReason

type SkipReason string

SkipReason represents why a record was skipped during ingest.

const (
	SkipReasonConflictManual SkipReason = "conflict_manual" // Existing manual source won
	SkipReasonConflictOlder  SkipReason = "conflict_older"  // Existing record has newer timestamp
	SkipReasonValidation     SkipReason = "validation"      // Row failed validation
	SkipReasonDatabase       SkipReason = "database"        // Database error during ingest
	SkipReasonOther          SkipReason = "other"           // Other skip reasons
)

func (SkipReason) String

func (r SkipReason) String() string

String returns the string representation of the skip reason.

type Source

type Source string

Source represents the provenance of an identity resolution.

const (
	SourceLive   Source = "live"   // Resolved by live enrichment worker
	SourceSeed   Source = "seed"   // From claude-leaderboard frozen cache
	SourceManual Source = "manual" // Hand-curated by operator
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL