views:

247

answers:

4

I am having trouble with committing a transaction (using Access 2003 DAO). It's acting as if I never had called BeginTrans -- I get error 3034 on CommitTrans, "You tried to commit or rollback a transaction without first beginning a transaction"; and the changes are written to the database (presumably because they were never wrapped in a transaction). However, BeginTrans is run, if you step through it.

  • I am running it within the Access environment using the DBEngine(0) workspace.
  • The tables I'm adding records to are all opened via a Jet database connection (to the same database) and using DAO.Recordset.AddNew / Update.
  • The connection is opened before starting BeforeTrans.
  • I'm not doing anything weird in the middle of the transaction like closing/opening connections or multiple workspaces etc.
  • There are two nested transaction levels. Basically it's wrapping multiple inserts in an outer transaction, so if any fail they all fail. The inner transactions run without errors, it's the outer transaction that doesn't work.

Here are a few things I've looked into and ruled out:

  • The transaction is spread across several methods and BeginTrans and CommitTrans (and Rollback) are all in different places. But when I tried a simple test of running a transaction this way, it doesn't seem like this should matter.

  • I thought maybe the database connection gets closed when it goes out of local scope, even though I have another 'global' reference to it (I'm never sure what DAO does with dbase connections to be honest). But this seems not to be the case -- right before the commit, the connection and its recordsets are alive (I can check their properties, EOF = False, etc.)

  • My CommitTrans and Rollback are done within event callbacks. (Very basically: a parser program is throwing an 'onLoad' event at the end of parsing, which I am handling by either committing or rolling back the inserts I made during processing, depending on if any errors occurred.) However, again, trying a simple test, it doesn't seem like this should matter.

Any ideas why this isn't working for me?

Thanks.

EDIT 25 May

Here is the (simplified) code. The key points having to do with the transaction are:

  • The workspace is DBEngine(0), referenced within the public (global) variable APPSESSION.
  • The database connection is opened in LoadProcess.cache below, see the line Set db = APPSESSION.connectionTo(dbname_).
  • BeginTrans is called in LoadProcess.cache.
  • CommitTrans is called in the process__onLoad callback.
  • Rollback is called in the process__onInvalid callback.
  • Recordset updates are done in process__onLoadRow, logLoadInit, and logLoad

Eric

'------------------- 
'Application globals
'-------------------

Public APPSESSION As DAOSession

'------------------
' Class LoadProcess
'------------------

Private WithEvents process_ As EventedParser
Private errs_ As New Collection

Private dbname_ As String
Private rawtable_ As String
Private logtable_ As String
Private isInTrans_ As Integer

Private raw_ As DAO.Recordset
Private log_ As DAO.Recordset
Private logid_ As Variant

Public Sub run
    '--- pre-load
    cache
    resetOnRun    ' resets load state variables per run, omitted here
    logLoadInit
    Set process_ = New EventedParser

    '--- load
    process_.Load
End Sub

' raised once per load() if any row invalid
Public Sub process__onInvalid(filename As String)
    If isInTrans_ Then APPSESSION.Workspace.Rollback
End Sub

' raised once per load() if all rows valid, after load
Public Sub process__onLoad(filename As String)
    If errs_.Count > 0 Then
        logLoadFail filename, errs_
    Else
        logLoadOK filename
    End If

    If isInTrans_ Then APPSESSION.Workspace.CommitTrans
End Sub

' raised once per valid row
' append data to raw_ recordset
Public Sub process__onLoadRow(row As Dictionary)
On Error GoTo Err_

    If raw_ Is Nothing Then GoTo Exit_   
    DAOext.appendFromHash raw_, row, , APPSESSION.Workspace

Exit_:
    Exit Sub

Err_:
    ' runtime error handling done here, code omitted
    Resume Exit_

End Sub


Private Sub cache()
Dim db As DAO.Database

    ' TODO raise error
    If Len(dbname_) = 0 Then GoTo Exit_       
    Set db = APPSESSION.connectionTo(dbname_)
    ' TODO raise error
    If db Is Nothing Then GoTo Exit_ 

    Set raw_ = db.OpenRecordset(rawtable_), dbOpenDynaset)
    Set log_ = db.OpenRecordset(logtable_), dbOpenDynaset)    

    APPSESSION.Workspace.BeginTrans
    isInTrans_ = True

Exit_:
    Set db = Nothing

End Sub

' Append initial record to log table
Private Sub logLoadInit()
Dim info As New Dictionary
On Error GoTo Err_

    ' TODO raise error?
    If log_ Is Nothing Then GoTo Exit_   

    With info
        .add "loadTime", Now
        .add "loadBy", CurrentUser
    End With

    logid_ = DAOext.appendFromHash(log_, info, , APPSESSION.Workspace)

Exit_:
    Exit Sub

Err_:
    ' runtime error handling done here, code omitted
    Resume Exit_

End Sub

Private Sub logLoadOK(filename As String)
    logLoad logid_, True, filename, New Collection
End Sub

Private Sub logLoadFail(filename As String, _
                      errs As Collection)
    logLoad logid_, False, filename, errs
End Sub

' Update log table record added in logLoadInit
Private Sub logLoad(logID As Variant, _
                    isloaded As Boolean, _
                    filename As String, _
                    errs As Collection)

Dim info As New Dictionary
Dim er As Variant, strErrs As String
Dim ks As Variant, k As Variant
On Error GoTo Err_

    ' TODO raise error?
    If log_ Is Nothing Then GoTo Exit_   
    If IsNull(logID) Then GoTo Exit_

    For Each er In errs
        strErrs = strErrs & IIf(Len(strErrs) = 0, "", vbCrLf) & CStr(er)
    Next Er

    With info
        .add "loadTime", Now
        .add "loadBy", CurrentUser
        .add "loadRecs", nrecs
        .add "loadSuccess", isloaded
        .add "loadErrs", strErrs
        .add "origPath", filename
    End With

    log_.Requery
    log_.FindFirst "[logID]=" & Nz(logID)
    If log_.NoMatch Then
        'TODO raise error
    Else
        log_.Edit
        ks = info.Keys
        For Each k In ks
            log_.Fields(k).Value = info(k)
        Next k
        log_.Update
    End If

Exit_:
    Exit Sub

Err_:
    ' runtime error handling done here, code omitted
    Resume Exit_

End Sub


'-------------
' Class DAOExt
'-------------
' append to recordset from Dictionary, return autonumber id of new record
Public Function appendFromHash(rst As DAO.Recordset, _
                          rec As Dictionary, _
                          Optional map As Dictionary, _
                          Optional wrk As DAO.workspace) As Long
Dim flds() As Variant, vals() As Variant, ifld As Long, k As Variant
Dim f As DAO.Field, rst_id As DAO.Recordset
Dim isInTrans As Boolean, isPersistWrk As Boolean
On Error GoTo Err_

    ' set up map (code omitted here)

    For Each k In rec.Keys
        If Not map.Exists(CStr(k)) Then _
            Err.Raise 3265, "appendFromHash", "No field mapping found for [" & CStr(k) & "]"
        flds(ifld) = map(CStr(k))
        vals(ifld) = rec(CStr(k))
        ifld = ifld + 1
    Next k

    If wrk Is Nothing Then
        isPersistWrk = False
        Set wrk = DBEngine(0)
    End If

    wrk.BeginTrans
        isInTrans = True
        rst.AddNew
        With rst
            For ifld = 0 To UBound(flds)
                .Fields(flds(ifld)).Value = vals(ifld)
            Next ifld
        End With
        rst.Update

        Set rst_id = wrk(0).OpenRecordset("SELECT @@Identity", DAO.dbOpenForwardOnly, DAO.dbReadOnly)
        appendFromHash = rst_id.Fields(0).Value

    wrk.CommitTrans
    isInTrans = False

Exit_:
    On Error GoTo 0
    If isInTrans And Not wrk Is Nothing Then wrk.Rollback
    If Not isPersistWrk Then Set wrk = Nothing
    Exit Function

Err_:
    ' runtime error handling, code omitted here
    Resume Exit_

End Function


'-----------------
' Class DAOSession (the part that deals with the workspace and dbase connections)
'-----------------
Private wrk_ As DAO.workspace
Private connects_ As New Dictionary
Private dbs_ As New Dictionary

Public Property Get workspace() As DAO.workspace
    If wrk_ Is Nothing Then
        If DBEngine.Workspaces.Count > 0 Then
            Set wrk_ = DBEngine(0)
        End If
    End If
    Set workspace = wrk_
End Property

Public Property Get connectionTo(dbname As String) As DAO.database
    connectTo dbname
    Set connectionTo = connects_(dbname)
End Property

Public Sub connectTo(dbname As String)
Dim Cancel As Integer
Dim cnn As DAO.database
Dim opts As Dictionary
    Cancel = False

    ' if already connected, use cached reference
    If connects_.Exists(dbname) Then GoTo Exit_

    If wrk_ Is Nothing Then _
        Set wrk_ = DBEngine(0)

    ' note opts is a dictionary of connection options, code omitted here
    Set cnn = wrk_.OpenDatabase(dbs_(dbname), _
                                CInt(opts("DAO.OPTIONS")), _
                                CBool(opts("DAO.READONLY")), _
                                CStr(opts("DAO.CONNECT")))

    ' Cache reference to dbase connection
    connects_.Add dbname, cnn

Exit_:
    Set cnn = Nothing
    Exit Sub

End Sub
A: 

Transactions are used by defining a workspace (it doesn't have to be a new one) and then beginning the transaction on that workspace, doing what you need to do with it, and then commiting the transaction if all is well. Skeletal code:

  On Error GoTo errHandler
    Dim wrk As DAO.Workspace

    Set wrk = DBEngine(0) ' use default workspace
    wrk.BeginTrans
    [do whatever]
    If [conditions are met] Then
       wrk.CommitTrans
    Else
       wrk.Rollback
    End If

  errHandler:
    Set wrk = Nothing

  exitRoutine:
    ' do whatever you're going to do with errors
    wrk.Rollback
    Resume errHandler

Now, within the block where you [do whatever], you can pass off the workspace and databases and recordsets to subroutines, but the top-level control structure should remain in one place.

Your code does not do that -- instead, you depend on global variables. GLOBAL VARIABLES ARE EVIL. Don't use them. Instead, pass private variables as parameters to the subroutines you want to operate on them. I would also say, never pass the workspace -- only pass the objects you've created with the workspace.

Once you've absorbed that, maybe it will help you explain what your code is supposed to accomplish (which I haven't the foggiest idea of from reading through it), and then we can advise you as to what you're doing wrong.

David-W-Fenton
Following your point about global variables... I agree with you that they are evil, but I find there is no good way around them in VBA for the kinds of applications I develop... I find the global objects Access gives you -- Application, DbEngine, etc. -- are not useful enough on their own. Where for instance do you keep user preferences so they're available everywhere? If you don't want to load them from disk every time you need them?
Eric G
I limit myself to just two globals...one for session data and one for managing which back-end databases to link to depending on if it's a development, testing or production environment. They are like wrappers for DbEngine(0) and DbEngine(0).Databases that let me do what I want instead of what Access wants me to do. I guess what I'm saying is it's just a practical choice, not a religious one.
Eric G
I would store user preferences in a table, and then wrap them in a class module for retrieval. Or, go the simple way, and load them from the table in textboxes in a hidden form that loads at application startup. I don't have much in the way of user preferences, so this is not something I use much.
David-W-Fenton
The comparison to DBEngine(0)(0) seems unwarranted. I don't use DBEngine(0)(0) but a cached reference initialized with CurrentDB. If you google my name and "dblocal" you'll find the code I use for that. But that isn't even the issue in your code. In your code, you need to pass objects between subroutines using parameters, as opposed to defining them globally (whether with global variables of global functions).
David-W-Fenton
I don't have much use for DBEngine(0). I don't do much coding that requires mucking about with the workspace. Likewise, the databases collection of the workspace is not something I have much need for since the only database that's relevant to me is the one open in the user interface. So, I don't see any justification for creating globals for those. If I needed them, I wouldn't use globals either -- I'd wrap them in functions, but I just don't get why you can't simply use DBEngine(0) and the DBEngine(0).Databases collection when you need them.
David-W-Fenton
My projects often have multiple back-end databases - in fact multiple sets of back-end databases. I found it useful to have a programmatic way of managing them (swapping linked tables, running remote queries on them, etc). Also as some (http://www.fmsinc.com/free/NewTips/Access/LinkedDatabase.asp) have suggested it helps performance to keep back-end database connections open. Yes I could use DBEngine everywhere, but to me it's easier to manage the internals in one place.
Eric G
A simple example: say I want a connection to database x. How do I know if it's already been opened in DBEngine(0).Databases? It would be costly to open it and close it every time. Better to have a class that caches the connection the first time, and returns it (already opened) each subsequent time.
Eric G
It sounds like you're not using linked tables. With linked tables you can use CurrentDB and don't have to worry about opening connections to other databases. In all my apps I use a function that caches a reference to the front end (with linked tables) initialized with CurrentDB.
David-W-Fenton
A: 

OK, after much frustrating debugging, I think I uncovered a bug in Jet transactions. After all that, it has nothing to do with my "enormously convoluted" code or "evil global variables" :)

It appears that when the following is true, you get the error #3034:

  • You open a snapshot-type recordset
  • The recordset is opened before you start the transaction
  • The recordset is closed/dereferenced after you begin the transaction, but before the commit or rollback.

I haven't checked if this is already known, although I can't imagine it isn't.

Of course, it's kind of weird to do things in this order anyway and asking for trouble, I don't know why I did it. I moved opening/closing the snapshot recordset to within the transaction and everything works fine.

The following code exhibits the error:

Public Sub run()
Dim db As DAO.Database, qdf As DAO.QueryDef, rst As DAO.Recordset
Dim wrk As DAO.Workspace, isInTrans As Boolean
On Error GoTo Err_

    Set wrk = DBEngine(0)
    Set db = wrk(0)
    Set rst = db.OpenRecordset("Table2", DAO.dbOpenSnapshot)

    wrk.BeginTrans
    isInTrans = True

    Set qdf = db.CreateQueryDef("", "INSERT INTO [Table1] (Field1, Field2) VALUES (""Blow"", ""Laugh"");")
    qdf.Execute dbFailOnError

Exit_:
    Set rst = Nothing
    Set qdf = Nothing
    Set db = Nothing
    If isInTrans Then wrk.CommitTrans
    isInTrans = False
    Exit Sub

Err_:
    MsgBox Err.Description
    If isInTrans Then wrk.Rollback
    isInTrans = False
    Resume Exit_

End Sub

And this fixes the error:

Public Sub run()
Dim db As DAO.Database, qdf As DAO.QueryDef, rst As DAO.Recordset
Dim wrk As DAO.Workspace, isInTrans As Boolean
On Error GoTo Err_

    Set wrk = DBEngine(0)
    Set db = wrk(0)

    wrk.BeginTrans
    isInTrans = True

    ' NOTE THIS LINE MOVED WITHIN THE TRANSACTION
    Set rst = db.OpenRecordset("Table2", DAO.dbOpenSnapshot)

    Set qdf = db.CreateQueryDef("", "INSERT INTO [Table1] (Field1, Field2) VALUES (""Blow"", ""Laugh"");")
    qdf.Execute dbFailOnError

Exit_:
    Set rst = Nothing
    Set qdf = Nothing
    Set db = Nothing
    If isInTrans Then wrk.CommitTrans
    isInTrans = False
    Exit Sub

Err_:
    MsgBox Err.Description
    If isInTrans Then wrk.Rollback
    isInTrans = False
    Resume Exit_

End Sub
Eric G
I don't understand two things about your code. The first is why you open the recordset. The second is why you create a temporary QueryDef instead of just executing the SQL. Doing the temp QueryDef is one of the hidden sources of database bloat and should be avoided, in my opinion. I can't see any advantage to it in any situation whatsoever.
David-W-Fenton
I suspect that if you created a new workspace and did your transactions inside that, it would not interfere with the recordset created outside the transaction *if* you used the default workspace for it.
David-W-Fenton
FWIW, I don't consider this a bug at all. I think you're just being sloppy with your workspaces.
David-W-Fenton
This is a simple example to show the error, not actual code I use. You could easily imagine a case where you needed to look up something in a table before you actually ran a transaction -- that's what I was doing more or less, although I took that part out of the example above. Thanks for the tip on executing SQL directly vs temp QueryDef.
Eric G
I only have one workspace and one database in this example code - the default workspace and default database. If you run the first subroutine above as-is, it will reproduce the error. It doesn't make sense to me that opening or closing a snapshot recordset changes anything about the workspace state, but it does -- it resets the transaction somehow. That's why I think it is a bug. But it's easy enough to avoid.
Eric G
A: 

David/Eric,

I can give you ONE relatively good reason to use a "temp" Querydef - it allows you to "test" our dynamic SQL prior to executing it (if you don't expect it to have errors after setting the SQL, re-open the querydef and count the Patrameters collection, if there are any, something is wrong with the SQL and it will not run).

Of course, nobody ever creates any bad dynamic SQL... so this is sort of a strawman excuse for using querydefs like that... ;-)

mburns_08109
A: 

Eric,

I just inadvertently duplicated your error in a situation within my applicaiton. I made a mistake and opened a dbOpenDynaset recordset BEFORE beginning a transaction, added a new record WITHIN a transaction (to the rs already opened) and then I did a stupidly placed Set oRs = Nothing within the transaction after updating the (outer) rs within the transaction and BEFORE firing off another append query to a related table (also within the transaction).

It bombed in a way similar your example - only in my case the 2nd insert failed because the transaction had been effectivly rolled back and RI-checks failed the new record inserts because the parent record had vanished.

What's interesting here though is that if that first recordset were instead a dbOpenTable recordset, everything ran flawlessly!

So, to my mind this DOES sound way more like a Jet 4 transaction processing bug, although a weird one.

mburns_08109
I'm having a hard time visualizing this. Anything you do before the transaction opens should be available inside the transaction, assuming you use the appropriate workspace. The transaction itself should probably use its own workspace, rather than the default workspace (if you look at the Access help files for transactions, you'll note they all use new workspaces). If you do that, changes to the objects initialized outside the transaction won't be available inside the transaction (as to be expected).
David-W-Fenton